@arcforgelabs/flow 2026.10.43 → 2026.10.45

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.
package/server/ui/app.js CHANGED
@@ -14,33 +14,19 @@ 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 { named, startTips } from '/assets/ui/tip.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';
25
27
 
26
28
  const $ = (id) => document.getElementById(id);
27
29
 
28
- // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
29
- function h(tag, attrs = {}, ...children) {
30
- const node = document.createElement(tag);
31
- for (const [key, value] of Object.entries(attrs ?? {})) {
32
- if (value == null || value === false) continue;
33
- if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
34
- else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
35
- else node.setAttribute(key, value === true ? '' : String(value));
36
- }
37
- for (const child of children.flat()) {
38
- if (child == null || child === false) continue;
39
- node.append(child instanceof Node ? child : document.createTextNode(String(child)));
40
- }
41
- return node;
42
- }
43
-
44
30
  const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
45
31
  const ROLE_HINT = {
46
32
  owner: 'Manages people, connections and every record.',
@@ -137,97 +123,8 @@ async function flow(path, options = {}) {
137
123
  return body;
138
124
  }
139
125
 
140
- // Keeps a button off for `ms`, with the time left on it, then puts it back.
141
- function holdButton(button, ms) {
142
- if (!button || !(ms > 0)) return;
143
- clearInterval(button.fxHold);
144
- const label = button.dataset.label ?? button.textContent;
145
- button.dataset.label = label;
146
- const until = Date.now() + ms;
147
- const draw = () => {
148
- const left = Math.ceil((until - Date.now()) / 1000);
149
- if (left <= 0) {
150
- clearInterval(button.fxHold);
151
- button.textContent = label;
152
- delete button.dataset.label;
153
- button.removeAttribute('title');
154
- button.disabled = false;
155
- return;
156
- }
157
- const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
158
- button.disabled = true;
159
- button.textContent = `${label} · ${time}`;
160
- button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
161
- };
162
- draw();
163
- button.fxHold = setInterval(draw, 1000);
164
- }
165
-
166
126
  // ── Shared pieces ──────────────────────────────────────────────────────────
167
127
 
168
- // The page's name and nothing under it: the page shows what it is for.
169
- // `count` is a live number beside the name (2 to check); `help` is a docs
170
- // link, an icon with its name as a tip.
171
- function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
172
- return h('header', { class: 'fx-ph' },
173
- h('div', { class: 'fx-ph__text' },
174
- h('h1', { class: 'fx-title' }, title),
175
- count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
176
- help ? helpLink(help) : null),
177
- actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
178
- }
179
-
180
- // A docs link as a small "?" icon whose tip names it (How sending works).
181
- function helpLink({ href, label }) {
182
- return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
183
- }
184
-
185
- // A settings section's heading, with its docs link beside it.
186
- function sectionTitle(text, help = null) {
187
- return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
188
- }
189
-
190
- function notice(text, tone = 'info', title = null) {
191
- return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
192
- title ? h('strong', {}, title) : null,
193
- h('span', {}, text));
194
- }
195
-
196
- function toast(text, tone = 'info') {
197
- const region = $('toasts');
198
- const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
199
- region.append(item);
200
- setTimeout(() => item.remove(), 4200);
201
- }
202
-
203
- // A modal built on <dialog>: resolves with the button's value, or null.
204
- function dialog({ title, body, confirm, tone = 'primary' }) {
205
- return new Promise((resolve) => {
206
- const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
207
- h('h2', { class: 'fx-dialog__title' }, title),
208
- body,
209
- h('div', { class: 'fx-dialog__actions' },
210
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
211
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
212
- const element = h('dialog', { class: 'fx-dialog' }, form);
213
- element.addEventListener('close', () => {
214
- const ok = element.returnValue === 'ok';
215
- const data = ok ? new FormData(form) : null;
216
- element.remove();
217
- resolve(ok ? data : null);
218
- });
219
- document.body.append(element);
220
- element.showModal();
221
- element.querySelector('input, select, button[value="ok"]')?.focus();
222
- });
223
- }
224
-
225
- function initials(person) {
226
- const source = (person.name || person.email || '?').replace(/@.*/, '');
227
- const parts = source.split(/[\s._-]+/).filter(Boolean);
228
- return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
229
- }
230
-
231
128
  function statusPill(person) {
232
129
  if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
233
130
  if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
@@ -245,30 +142,32 @@ function roleSelect(value, attrs = {}) {
245
142
 
246
143
  let me = null;
247
144
 
248
- // The sidebar lists the views in this order, under their group's heading.
145
+ // Where each view sits: the sidebar, in this order, daily work first (no
146
+ // `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
147
+ // icon); the account menu (`place: 'account'`). `sidebar: false` views open
148
+ // by link and Search only.
249
149
  const VIEWS = {
250
- jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
251
- invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
252
- reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
253
- 'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
254
- automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
255
- 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
256
- 'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
257
- people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
258
- integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
150
+ jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
151
+ invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
152
+ reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
153
+ 'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
154
+ 'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
155
+ automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
156
+ 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
157
+ integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
158
+ people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
259
159
  // Not in the sidebar: "Manage account" in the account menu opens the same
260
160
  // Clerk profile. The page stays for links and Search.
261
161
  // Opened from the Xero integration page: Xero invoices against Flow's own.
262
- 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
263
- account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
264
- appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
162
+ 'xero-import': { label: 'Xero import', icon: 'plug', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
163
+ account: { label: 'Account', icon: 'person', sidebar: false, render: renderAccount },
164
+ appearance: { label: 'Appearance', icon: 'palette', place: 'account', render: renderAppearance },
265
165
  };
266
166
 
267
167
  // Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
268
168
  const ICON_PATHS = {
269
169
  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"/>',
270
170
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
271
- draft: '<path d="M14 3H6v18h5M14 3l4 4v3M14 3v4h4"/><path d="M13 21v-2.5l6-6 2.5 2.5-6 6z"/>',
272
171
  bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
273
172
  rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
274
173
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
@@ -280,6 +179,7 @@ const ICON_PATHS = {
280
179
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
281
180
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
282
181
  sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
182
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
283
183
  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"/>',
284
184
  };
285
185
 
@@ -342,7 +242,7 @@ document.addEventListener('keydown', (event) => {
342
242
  }
343
243
  });
344
244
  document.addEventListener('click', (event) => {
345
- if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
245
+ if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]:not([data-nav="more"])')) closeMenu();
346
246
  });
347
247
  // Folded, a click on the sidebar's empty space opens it out again, as in
348
248
  // Claude. A click on a page, the account photo or Search still does its job.
@@ -362,7 +262,7 @@ function search() {
362
262
  }
363
263
 
364
264
  document.addEventListener('keydown', (event) => {
365
- if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
265
+ if (!isSearchKey(event)) return;
366
266
  if (document.body.dataset.state !== 'signed-in') return;
367
267
  event.preventDefault();
368
268
  search();
@@ -370,15 +270,19 @@ document.addEventListener('keydown', (event) => {
370
270
 
371
271
  // "Report a problem" in Clerk's account menu, between "Manage account" and
372
272
  // "Sign out". Clerk mounts and unmounts the icon itself.
373
- function reportMenuItem(openReport) {
273
+ function accountMenuItem(label, iconName, onClick) {
374
274
  return {
375
- label: 'Report a problem',
376
- onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
377
- mountIcon: (element) => element.replaceChildren(icon('flag')),
275
+ label,
276
+ onClick,
277
+ mountIcon: (element) => element.replaceChildren(icon(iconName)),
378
278
  unmountIcon: (element) => element.replaceChildren(),
379
279
  };
380
280
  }
381
281
 
282
+ function reportMenuItem(openReport) {
283
+ return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
284
+ }
285
+
382
286
  // The page a person lands on after sign-in, and for / or an unknown path.
383
287
  // Invoices opens on its Ready tab. A home page can take this over later.
384
288
  const START_VIEW = 'invoices';
@@ -399,6 +303,8 @@ async function route() {
399
303
  for (const link of document.querySelectorAll('a[data-nav]')) {
400
304
  link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
401
305
  }
306
+ // More shows as current while one of its pages is open.
307
+ document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
402
308
  main.replaceChildren();
403
309
  main.className = 'fx-main';
404
310
  document.title = `${VIEWS[view].label} · Flow`;
@@ -407,6 +313,8 @@ async function route() {
407
313
  }
408
314
 
409
315
  // Opens a path in place; the address changes, the page stays.
316
+ linksOpenWith((path) => go(path));
317
+
410
318
  function go(path, { replace = false } = {}) {
411
319
  if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
412
320
  route();
@@ -422,7 +330,7 @@ function navigate(event) {
422
330
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
423
331
  // takes a path, or a link's click event.
424
332
  const ui = () => ({
425
- h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
333
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
426
334
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
427
335
  });
428
336
 
@@ -454,14 +362,17 @@ function renderShell() {
454
362
  document.body.dataset.state = 'signed-in';
455
363
  const nav = $('nav');
456
364
  const items = [];
457
- let group = null;
365
+ const more = [];
458
366
  for (const [name, view] of Object.entries(VIEWS)) {
459
367
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
460
- if (view.sidebar === false) continue;
461
- if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
462
- group = view.group;
463
- items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
368
+ if (view.sidebar === false || view.place === 'account') continue;
369
+ if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
370
+ else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
464
371
  }
372
+ // The rest of the setup pages, behind More (the shared ⋯ menu).
373
+ const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
374
+ Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
375
+ items.push(moreButton, morePanel);
465
376
  // "Report a problem" sits at the foot of the menu, above the account chip,
466
377
  // while Sentry and its report form are on (monitoring.js).
467
378
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
@@ -478,7 +389,11 @@ function renderShell() {
478
389
  drawMenu();
479
390
  // The sidebar foot shows who is signed in: photo or initial, then the name
480
391
  // (the email when the account has no name). It opens the account menu.
481
- const userButton = { afterSignOutUrl: '/sign-in', showName: true };
392
+ // People (owners) and Appearance live in the account menu.
393
+ const accountItems = Object.entries(VIEWS)
394
+ .filter(([, view]) => view.place === 'account' && (!view.ownerOnly || me?.person.role === 'owner'))
395
+ .map(([name, view]) => accountMenuItem(view.label, view.icon, () => go(`/${name}`)));
396
+ const userButton = { afterSignOutUrl: '/sign-in', showName: true, customMenuItems: accountItems };
482
397
  $('search').replaceChildren(icon('search'));
483
398
  $('search').onclick = search;
484
399
  mountClerk('userButton', $('user-button'), userButton);
@@ -491,7 +406,7 @@ function renderShell() {
491
406
  report.hidden = false;
492
407
  report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
493
408
  unmountClerk($('user-button'));
494
- mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
409
+ mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [...accountItems, reportMenuItem(openReport)] });
495
410
  })
496
411
  .catch((error) => console.warn(`[monitoring] ${error.message}`));
497
412
  window.addEventListener('popstate', route);
@@ -548,28 +463,6 @@ async function refreshPeople(host) {
548
463
  ].filter(Boolean));
549
464
  }
550
465
 
551
- let peopleMenus = 0;
552
-
553
- // A row's ⋯ menu, as on the Custom fields page.
554
- function rowMenu(label, items) {
555
- const id = `fx-people-menu-${++peopleMenus}`;
556
- const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
557
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
558
- panel.addEventListener('toggle', (event) => {
559
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
560
- if (event.newState !== 'open') return;
561
- const close = () => panel.hidePopover();
562
- panel.replaceChildren(...items.map((entry) => (entry === '-'
563
- ? h('div', { class: 'fx-pop__sep' })
564
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
565
- h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
566
- const at = button.getBoundingClientRect();
567
- panel.style.top = `${Math.round(at.bottom + 6)}px`;
568
- panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
569
- });
570
- return [button, panel];
571
- }
572
-
573
466
  function personRow(person, host) {
574
467
  const self = person.id === me.person.id;
575
468
  const act = async (fn, done) => {
@@ -583,9 +476,9 @@ function personRow(person, host) {
583
476
  };
584
477
  const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
585
478
  const who = h('div', { class: 'fx-person' },
586
- h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initials(person)),
479
+ h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(person)),
587
480
  h('span', { class: 'fx-person__text' },
588
- h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
481
+ h('span', { class: 'fx-person__name' }, personLabel(person), self ? h('span', { class: 'fx-tag' }, 'You') : null),
589
482
  person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
590
483
  const role = self
591
484
  ? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
@@ -728,27 +621,6 @@ function integrationTile(integration) {
728
621
  });
729
622
  }
730
623
 
731
- // One card in a grid of choices (Integrations, Rules): its mark (a node, or
732
- // an ICON_PATHS name), the name, a status pill, two lines at most (only when
733
- // they help), and the action. The whole card is the link.
734
- function tileCard({ href, mark, title, status, blurb, action = 'Manage' }) {
735
- const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, icon(mark)) : mark;
736
- return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: navigate },
737
- h('header', { class: 'fx-int-card__head' },
738
- logo,
739
- h('h3', {}, title),
740
- status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null),
741
- blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
742
- h('span', { class: 'fx-int-card__btn' }, action));
743
- }
744
-
745
- // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
746
- function crumbs(...trail) {
747
- const here = trail.pop();
748
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
749
- ...trail.flatMap(([href, label]) => [h('a', { href, onclick: navigate }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
750
- h('span', {}, here));
751
- }
752
624
 
753
625
  // One integration's page (/integrations/<id>): its logo and maker, Connect
754
626
  // with each step shown as it happens, the setup steps, its settings, and the
@@ -959,22 +831,6 @@ function personSettingsSection(integration) {
959
831
  return section;
960
832
  }
961
833
 
962
- // One setting, laid out the same on every settings screen: a short name, one
963
- // line of smaller text under it, and the control on the right. Longer help
964
- // lives behind a link, not here. A wide control (a grid of choices) sits on
965
- // its own line under the name. `tone: 'danger'` marks a row that needs a person.
966
- function settingRow({ label, hint, control, wide = false, tone = null }) {
967
- const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
968
- const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
969
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
970
- h('div', { class: 'fx-setting__text' },
971
- id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
972
- hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
973
- h('div', { class: 'fx-setting__control' }, control));
974
- }
975
- settingRow.next = 0;
976
-
977
- // The steps of a Connect, each ticked, running or crossed, with the time taken.
978
834
  function connectProgress({ steps, started }) {
979
835
  const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
980
836
  return h('div', { class: 'fx-progress', role: 'status' },