@arcforgelabs/flow 2026.10.48 → 2026.10.49

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/kit.js CHANGED
@@ -149,10 +149,31 @@ export function sectionTitle(text, help = null) {
149
149
  return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
150
150
  }
151
151
 
152
- export function notice(text, tone = 'info', title = null) {
152
+ // With `short`, a phone shows only that one line ("Client not in Xero") in
153
+ // place of the title and text.
154
+ export function notice(text, tone = 'info', title = null, { short = null } = {}) {
153
155
  return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
154
- title ? h('strong', {}, title) : null,
155
- h('span', {}, text)), 'notice');
156
+ title ? h('strong', { class: short ? 'fx-notice__long' : null }, title) : null,
157
+ h('span', { class: short ? 'fx-notice__long' : null }, text),
158
+ short ? h('span', { class: 'fx-notice__short' }, short) : null), 'notice');
159
+ }
160
+
161
+ // A table that is a list of small cards on a phone (CardRows card). On a
162
+ // tablet and desk it stays a table. `areas` places each cell on the card
163
+ // (CSS grid-template-areas, one string per line) and `columns` sizes the
164
+ // card's columns. A cell names its place with `data-area` and its column
165
+ // name with `data-label` (shown above it on a phone). Rows drawn later are
166
+ // placed too.
167
+ export function cardRows(table, { areas, columns }) {
168
+ table.classList.add('fx-cardrows');
169
+ table.style.setProperty('--fx-cardrows-areas', areas.map((line) => `"${line}"`).join(' '));
170
+ table.style.setProperty('--fx-cardrows-columns', columns);
171
+ const place = () => {
172
+ for (const cell of table.querySelectorAll('td[data-area]')) cell.style.gridArea = cell.dataset.area;
173
+ };
174
+ place();
175
+ if (typeof MutationObserver === 'function') new MutationObserver(place).observe(table, { childList: true, subtree: true });
176
+ return applyPhoneRule(table, 'cardRows');
156
177
  }
157
178
 
158
179
  // A field that fails a check glows red, like a required field, until the
@@ -220,17 +241,87 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
220
241
  });
221
242
  }
222
243
 
223
- // One setting, laid out the same on every settings screen: a short name, one
224
- // line of smaller text under it, and the control on the right. Longer help
225
- // lives behind a link, not here. A wide control (a grid of choices) sits on
226
- // its own line under the name. `tone: 'danger'` marks a row that needs a person.
227
- export function settingRow({ label, hint, control, wide = false, tone = null }) {
244
+ // A dialog that picks one row of a long list (Pick card). A search box over
245
+ // the list; `load(q)` answers the rows for its text (once with '' on open, and
246
+ // again as the person types when `remote`; else the rows filter here).
247
+ // `item(row)` draws a row as { value, title, meta, end }. Answers the picked
248
+ // row, or null on Cancel.
249
+ export async function pickOne({ title, intro = null, label, find = 'Find', load, item, empty, confirm, remote = false }) {
250
+ const search = h('input', { class: 'fx-input', type: 'search', placeholder: find, 'aria-label': find });
251
+ const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': label }, h('div', { class: 'fx-skeleton' }));
252
+ let rows = [];
253
+ const draw = (found) => {
254
+ rows = found.map((row) => {
255
+ const { value, title: name, meta = null, end = null } = item(row);
256
+ const element = h('label', { class: 'fx-pick__row' },
257
+ h('input', { type: 'radio', name: 'pick', value: String(value), required: true }),
258
+ h('span', { class: 'fx-pick__main' },
259
+ h('span', { class: 'fx-strong' }, name),
260
+ meta ? h('span', { class: 'fx-dim' }, meta) : null),
261
+ end == null ? h('span') : h('span', { class: 'fx-num fx-strong' }, end));
262
+ element.dataset.find = `${name} ${meta ?? ''}`.toLowerCase();
263
+ return { row, value: String(value), element };
264
+ });
265
+ list.replaceChildren(...(rows.length ? rows.map((r) => r.element) : [h('p', { class: 'fx-dim' }, empty)]));
266
+ };
267
+ let asked = 0;
268
+ const fill = async (q) => {
269
+ const turn = ++asked;
270
+ let found;
271
+ try {
272
+ found = await load(q);
273
+ } catch (error) {
274
+ found = [];
275
+ toast(error.message, 'danger');
276
+ }
277
+ if (turn === asked) draw(found);
278
+ };
279
+ let wait = null;
280
+ search.addEventListener('input', () => {
281
+ const q = search.value.trim().toLowerCase();
282
+ if (remote) {
283
+ clearTimeout(wait);
284
+ wait = setTimeout(() => fill(q), 250);
285
+ return;
286
+ }
287
+ for (const r of rows) r.element.hidden = Boolean(q) && !r.element.dataset.find.includes(q);
288
+ });
289
+ fill('');
290
+ const picked = await dialog({
291
+ title,
292
+ body: applyPhoneRule(h('div', { class: 'fx-pick__body' }, intro ? h('p', { class: 'fx-dim' }, intro) : null, search, list), 'pickOne'),
293
+ confirm,
294
+ });
295
+ clearTimeout(wait);
296
+ const value = picked?.get('pick');
297
+ return value ? rows.find((r) => r.value === value)?.row ?? null : null;
298
+ }
299
+
300
+ // One setting, laid out the same on every settings screen: a short name and
301
+ // the control on the right. `hint` is a short status line under the name
302
+ // ("3 to check"), never an explanation. `help` is the explanation: a ? beside
303
+ // the name says it on hover, and a press shows it under the name (a phone has
304
+ // no hover). A wide control (a grid of choices) sits on its own line under the
305
+ // name. `tone: 'danger'` marks a row that needs a person.
306
+ export function settingRow({ label, hint, help = null, control, wide = false, tone = null }) {
228
307
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
229
308
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
309
+ const helpLine = help ? h('p', { class: 'fx-setting__hint fx-setting__help', hidden: true }, help) : null;
310
+ const helpButton = help ? named(h('button', {
311
+ class: 'fx-help', type: 'button', 'aria-expanded': 'false',
312
+ onclick: (event) => {
313
+ const open = helpLine.hidden;
314
+ helpLine.hidden = !open;
315
+ event.currentTarget.setAttribute('aria-expanded', String(open));
316
+ },
317
+ }, lucide(h, 'circle-help')), `About ${label}`, help) : null;
230
318
  return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
231
319
  h('div', { class: 'fx-setting__text' },
232
- id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
233
- hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
320
+ h('span', { class: 'fx-setting__head' },
321
+ id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
322
+ helpButton),
323
+ hint ? h('p', { class: 'fx-setting__hint' }, hint) : null,
324
+ helpLine),
234
325
  h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
235
326
  }
236
327
  settingRow.next = 0;
@@ -9,14 +9,12 @@
9
9
  const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
10
10
 
11
11
  export function lineMappingSection(ui) {
12
- const { h, flow, toast, notice, me } = ui;
12
+ const { h, flow, toast, notice, me, settingRow } = ui;
13
13
  const disabled = !['owner', 'admin'].includes(me?.person.role);
14
14
  const body = h('div', {}, h('div', { class: 'fx-skeleton' }));
15
15
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Price codes'), body);
16
16
 
17
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
18
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
19
- h('div', { class: 'fx-set__control' }, control));
17
+ const row = (label, hint, control) => settingRow({ label, hint, control });
20
18
 
21
19
  async function load() {
22
20
  let labels;
@@ -68,6 +68,26 @@ export const RULES = {
68
68
  "mark": false,
69
69
  "card": "Button"
70
70
  },
71
+ "cardRows": {
72
+ "component": "cardRows",
73
+ "direction": "card per row",
74
+ "phone": {
75
+ "layout": "cards"
76
+ },
77
+ "tablet": {
78
+ "layout": "table"
79
+ },
80
+ "desk": {
81
+ "layout": "table"
82
+ },
83
+ "touch": 44,
84
+ "pageOverflow": "none",
85
+ "screens": [
86
+ "invoice",
87
+ "invoice-issued"
88
+ ],
89
+ "card": "CardRows"
90
+ },
71
91
  "columnTable": {
72
92
  "component": "columnTable",
73
93
  "direction": "two-line row",
@@ -281,6 +301,7 @@ export const RULES = {
281
301
  },
282
302
  "touch": 44,
283
303
  "pageOverflow": "none",
304
+ "scrollX": ".fx-frame, .fx-board, .fx-board-tabs",
284
305
  "screens": [
285
306
  "jobs-board",
286
307
  "jobs-list",
@@ -308,6 +329,7 @@ export const RULES = {
308
329
  },
309
330
  "touch": 44,
310
331
  "pageOverflow": "none",
332
+ "scrollX": ".fx-toolbar__tools",
311
333
  "screens": [
312
334
  "jobs-board",
313
335
  "jobs-list",
@@ -366,7 +388,8 @@ export const RULES = {
366
388
  "component": "notice",
367
389
  "direction": "note",
368
390
  "phone": {
369
- "layout": "note"
391
+ "layout": "note",
392
+ "short": "one line in place of the title and text"
370
393
  },
371
394
  "tablet": {
372
395
  "layout": "note"
@@ -376,7 +399,9 @@ export const RULES = {
376
399
  },
377
400
  "touch": 44,
378
401
  "pageOverflow": "none",
379
- "screens": [],
402
+ "screens": [
403
+ "invoice-offer"
404
+ ],
380
405
  "card": "Notice"
381
406
  },
382
407
  "optionPill": {
@@ -452,6 +477,23 @@ export const RULES = {
452
477
  "selector": ".fx-pager",
453
478
  "card": "Pager"
454
479
  },
480
+ "pickOne": {
481
+ "component": "pickOne",
482
+ "direction": "sheet list",
483
+ "phone": {
484
+ "layout": "sheet"
485
+ },
486
+ "tablet": {
487
+ "layout": "dialog"
488
+ },
489
+ "desk": {
490
+ "layout": "dialog"
491
+ },
492
+ "touch": 44,
493
+ "pageOverflow": "none",
494
+ "screens": [],
495
+ "card": "Pick"
496
+ },
455
497
  "rowMenu": {
456
498
  "component": "rowMenu",
457
499
  "direction": "menu",
@@ -511,7 +553,9 @@ export const RULES = {
511
553
  "touch": 44,
512
554
  "pageOverflow": "none",
513
555
  "screens": [
514
- "appearance"
556
+ "appearance",
557
+ "integration-xero",
558
+ "integration-seazona"
515
559
  ],
516
560
  "card": "SettingRow"
517
561
  },
@@ -548,6 +592,7 @@ export const RULES = {
548
592
  },
549
593
  "touch": 44,
550
594
  "pageOverflow": "none",
595
+ "scrollX": ".fx-tabs",
551
596
  "screens": [
552
597
  "invoices-ready",
553
598
  "invoices-pending",
@@ -34,7 +34,7 @@ const toCents = (text) => {
34
34
  };
35
35
 
36
36
  export async function renderPriceList(main, ui) {
37
- const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
37
+ const { h, flow, toast, notice, dialog, pageHeader, me, settingRow } = ui;
38
38
  const disabled = !['owner', 'admin'].includes(me?.person.role);
39
39
  const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
40
40
  let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
@@ -71,9 +71,7 @@ export async function renderPriceList(main, ui) {
71
71
  }
72
72
  }
73
73
 
74
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
75
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
76
- h('div', { class: 'fx-set__control fx-rule__controls' }, control));
74
+ const row = (label, hint, control) => settingRow({ label, hint, control });
77
75
 
78
76
  const findingText = (f) => {
79
77
  const name = systemName(f.system);
@@ -165,7 +163,7 @@ export async function renderPriceList(main, ui) {
165
163
  else select.value = saved;
166
164
  select.disabled = disabled;
167
165
  });
168
- return row('Line price from', h('span', { class: 'fx-set__hint' }, 'Where an invoice line takes its price.'), select);
166
+ return row('Line price from', 'Where an invoice line takes its price.', select);
169
167
  }
170
168
 
171
169
  function draw() {
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
24
+ export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';
@@ -0,0 +1,101 @@
1
+ // Link and Create for a Flow contact that is not a Xero contact
2
+ // (docs/CONTACTS.md). The Pending invoice and the contact page both show
3
+ // them. Link picks the Xero client when the names differ or Xero has the
4
+ // name twice; Flow writes only its own link. Create adds the client to Xero.
5
+ // `done(outcome)` runs after either one works.
6
+
7
+ const SMALL = new Set(['the', 'and', 'of', 'pty', 'ltd', 'limited', 'co', 'inc']);
8
+
9
+ function words(name) {
10
+ return new Set(String(name ?? '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').split(' ')
11
+ .filter((word) => word && !SMALL.has(word)));
12
+ }
13
+
14
+ // How many words two names share.
15
+ export function closeness(a, b) {
16
+ const mine = words(a);
17
+ let shared = 0;
18
+ for (const word of words(b)) if (mine.has(word)) shared += 1;
19
+ return shared;
20
+ }
21
+
22
+ // The Xero contacts nearest the name first, then A to Z.
23
+ export function nearestFirst(name, contacts) {
24
+ return contacts
25
+ .map((contact) => ({ contact, score: closeness(name, contact.name) }))
26
+ .sort((a, b) => b.score - a.score || a.contact.name.localeCompare(b.contact.name))
27
+ .map(({ contact }) => contact);
28
+ }
29
+
30
+ async function xeroContacts(ui, organisationId, q) {
31
+ const query = new URLSearchParams({ organisationId, q, limit: '1000' });
32
+ const { contacts } = await ui.flow(`/api/xero/contacts?${query}`);
33
+ return contacts.filter((contact) => contact.status === 'ACTIVE');
34
+ }
35
+
36
+ export function xeroContactActions(ui, { organisationId, contact, offerLink = false, offerCreate = false, done }) {
37
+ const { h, flow, toast } = ui;
38
+ const failed = (button, error) => {
39
+ toast(error.message, error.status === 429 ? 'info' : 'danger');
40
+ if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
41
+ else button.disabled = false;
42
+ };
43
+
44
+ const link = offerLink ? h('button', {
45
+ class: `fx-btn ${offerCreate ? 'fx-btn--ghost' : 'fx-btn--primary'}`, type: 'button', title: 'Link this client to its Xero client',
46
+ onclick: async (event) => {
47
+ const button = event.currentTarget;
48
+ const picked = await ui.pickOne({
49
+ title: `Link ${contact.name} to Xero`,
50
+ intro: 'Pick the Xero client. Flow links the two. Xero does not change.',
51
+ label: 'Xero clients',
52
+ find: 'Find a Xero client',
53
+ remote: true,
54
+ load: async (q) => nearestFirst(contact.name, await xeroContacts(ui, organisationId, q)),
55
+ item: (row) => ({ value: row.xeroContactId, title: row.name, meta: [row.email, row.accountNumber].filter(Boolean).join(' · ') }),
56
+ empty: 'No Xero client has that name. Sync Xero, or use Create.',
57
+ confirm: 'Link',
58
+ });
59
+ if (!picked) return;
60
+ button.disabled = true;
61
+ try {
62
+ await flow('/api/xero/contacts/link', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id, xeroContactId: picked.xeroContactId }) });
63
+ toast(`${contact.name} is linked to ${picked.name} in Xero.`, 'success');
64
+ await done('linked');
65
+ } catch (error) {
66
+ failed(button, error);
67
+ }
68
+ },
69
+ }, 'Link') : null;
70
+
71
+ const create = offerCreate ? h('button', {
72
+ class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
73
+ onclick: async (event) => {
74
+ const button = event.currentTarget;
75
+ // A close name in Xero is often this client: say so before a second one.
76
+ let close = [];
77
+ try {
78
+ close = (await xeroContacts(ui, organisationId, '')).filter((row) => closeness(contact.name, row.name) > 0);
79
+ } catch { /* Create still works without the list. */ }
80
+ const names = nearestFirst(contact.name, close).slice(0, 3).map((row) => row.name);
81
+ const confirmed = await ui.dialog({
82
+ title: `Create ${contact.name} in Xero?`,
83
+ body: h('div', { class: 'fx-pick__body' },
84
+ h('p', {}, 'Flow adds this client to Xero from the contact.'),
85
+ names.length ? ui.notice(`Xero has ${names.join(', ')}. If one is this client, use Link.`, 'info', 'Close names.') : null),
86
+ confirm: 'Create',
87
+ });
88
+ if (!confirmed) return;
89
+ button.disabled = true;
90
+ try {
91
+ await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
92
+ toast(`${contact.name} is in Xero.`, 'success');
93
+ await done('created');
94
+ } catch (error) {
95
+ failed(button, error);
96
+ }
97
+ },
98
+ }, 'Create') : null;
99
+
100
+ return [link, create].filter(Boolean);
101
+ }