@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
package/server/ui/app.js CHANGED
@@ -18,12 +18,14 @@ 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
+ import { xeroUsage } from '/assets/ui/xero-usage.js';
21
22
  import { startMonitoring } from '/assets/ui/monitoring.js';
22
23
  import { isSearchKey, openSearch } from '/assets/ui/search.js';
23
24
  import { alertStrip } from '/assets/ui/alerts.js';
24
25
  import { initialsOf, personLabel } from '/assets/ui/person.js';
25
26
  import { startTips } from '/assets/ui/tip.js';
26
27
  import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
28
+ import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
27
29
 
28
30
  const $ = (id) => document.getElementById(id);
29
31
 
@@ -142,19 +144,20 @@ function roleSelect(value, attrs = {}) {
142
144
 
143
145
  let me = null;
144
146
 
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.
147
+ // Where each view sits, in sidebar order, daily work first. No `place`: a
148
+ // sidebar page, pinned until the person unpins it. `place: 'more'`: a sidebar
149
+ // page that starts in More until they pin it. `place: 'account'`: the account
150
+ // menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
151
+ // cannot be unpinned (nav-pins.js).
149
152
  const VIEWS = {
150
153
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
151
154
  invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
152
155
  reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
153
156
  'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
154
157
  '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
+ automations: { label: 'Automations', icon: 'bolt', place: 'more', render: (main) => renderAutomations(main, ui()) },
159
+ 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', render: (main) => renderCustomFields(main, ui()) },
160
+ integrations: { label: 'Integrations', icon: 'plug', place: 'more', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
158
161
  people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
159
162
  // Not in the sidebar: "Manage account" in the account menu opens the same
160
163
  // Clerk profile. The page stays for links and Search.
@@ -300,11 +303,7 @@ async function route() {
300
303
  const [view, id] = currentView();
301
304
  const path = id ? `/${view}/${id}` : `/${view}`;
302
305
  if (location.pathname !== path) history.replaceState(null, '', path);
303
- for (const link of document.querySelectorAll('a[data-nav]')) {
304
- link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
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');
306
+ markNavCurrent(view);
308
307
  main.replaceChildren();
309
308
  main.className = 'fx-main';
310
309
  document.title = `${VIEWS[view].label} · Flow`;
@@ -358,26 +357,108 @@ function renderSignedOut() {
358
357
  });
359
358
  }
360
359
 
360
+ // Sidebar pages this person may open. Account pages stay in the account menu.
361
+ function sidebarViews() {
362
+ return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
363
+ }
364
+
365
+ // Null until the shell reads the account. Jobs and Invoices are always in it.
366
+ let pinned = null;
367
+ let pinEpoch = 0;
368
+ let pinWrite = Promise.resolve();
369
+ let morePanel = null;
370
+
371
+ function pinSet() {
372
+ if (!pinned) {
373
+ pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
374
+ for (const name of NAV_LOCKED) pinned.add(name);
375
+ }
376
+ return pinned;
377
+ }
378
+
379
+ function navLink(name, view) {
380
+ return h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label));
381
+ }
382
+
383
+ // More is the current page while the open page is a sidebar page that is
384
+ // not pinned, so it has no row of its own.
385
+ function markNavCurrent(view = currentView()[0]) {
386
+ for (const link of document.querySelectorAll('a[data-nav]')) {
387
+ link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
388
+ }
389
+ const shown = [...document.querySelectorAll('a[data-nav]')].some((link) => link.dataset.nav === view);
390
+ const inSidebar = sidebarViews().some(([name]) => name === view);
391
+ document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', inSidebar && !shown ? 'page' : 'false');
392
+ }
393
+
394
+ function syncSidebar() {
395
+ const nav = $('nav');
396
+ const moreButton = nav?.querySelector('[data-nav="more"]');
397
+ if (!moreButton) return;
398
+ for (const link of [...nav.querySelectorAll('a[data-nav]')]) link.remove();
399
+ const links = sidebarViews().filter(([name]) => pinSet().has(name)).map(([name, view]) => navLink(name, view));
400
+ if (links.length) moreButton.before(...links);
401
+ morePanel?.refresh?.();
402
+ drawMenu();
403
+ markNavCurrent();
404
+ }
405
+
406
+ function moreItems() {
407
+ const pins = pinSet();
408
+ return sidebarViews().map(([name, view]) => {
409
+ const on = pins.has(name);
410
+ const locked = NAV_LOCKED.includes(name);
411
+ return {
412
+ icon: icon(view.icon),
413
+ text: view.label,
414
+ run: () => { closeMenu(); go(`/${name}`); },
415
+ trailing: locked
416
+ ? { icon: 'pin', label: 'Pinned', pressed: true, locked: true }
417
+ : { icon: 'pin', label: on ? 'Unpin' : 'Pin', pressed: on, run: () => togglePin(name) },
418
+ };
419
+ });
420
+ }
421
+
422
+ function togglePin(name) {
423
+ if (NAV_LOCKED.includes(name)) return;
424
+ const next = new Set(pinSet());
425
+ if (next.has(name)) next.delete(name);
426
+ else next.add(name);
427
+ for (const locked of NAV_LOCKED) next.add(locked);
428
+ const epoch = ++pinEpoch;
429
+ pinned = next;
430
+ syncSidebar();
431
+ const body = { pinned: [...next] };
432
+ pinWrite = pinWrite.then(() => savePins(body, epoch), () => savePins(body, epoch));
433
+ }
434
+
435
+ async function savePins(body, epoch) {
436
+ try {
437
+ const saved = await flow('/api/me/views/nav', { method: 'PUT', body: JSON.stringify(body) });
438
+ if (epoch !== pinEpoch) return;
439
+ pinned = new Set(saved.view.pinned);
440
+ me.views = { ...(me.views ?? {}), nav: saved.view };
441
+ syncSidebar();
442
+ } catch (error) {
443
+ if (epoch !== pinEpoch) return;
444
+ pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
445
+ for (const locked of NAV_LOCKED) pinned.add(locked);
446
+ syncSidebar();
447
+ toast(error.message, 'danger');
448
+ }
449
+ }
450
+
361
451
  function renderShell() {
362
452
  document.body.dataset.state = 'signed-in';
363
453
  const nav = $('nav');
364
- const items = [];
365
- const more = [];
366
- for (const [name, view] of Object.entries(VIEWS)) {
367
- if (view.ownerOnly && me?.person.role !== 'owner') continue;
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)));
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' });
454
+ const [moreButton, panel] = rowMenu('More', moreItems, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start', place: 'side' });
455
+ morePanel = panel;
374
456
  Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
375
- items.push(moreButton, morePanel);
376
457
  // "Report a problem" sits at the foot of the menu, above the account chip,
377
458
  // while Sentry and its report form are on (monitoring.js).
378
459
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
379
460
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
380
- nav.replaceChildren(...items, report);
461
+ nav.replaceChildren(moreButton, panel, report);
381
462
  // Folded or narrow, the logo is the sidebar button: it shows the mark and
382
463
  // turns into the sidebar icon under the pointer or keyboard focus.
383
464
  const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
@@ -386,7 +467,7 @@ function renderShell() {
386
467
  sidebar.classList.add('fx-toggle__icon');
387
468
  $('menu-toggle').replaceChildren(mark, sidebar);
388
469
  $('menu-toggle').onclick = toggleMenu;
389
- drawMenu();
470
+ syncSidebar();
390
471
  // The sidebar foot shows who is signed in: photo or initial, then the name
391
472
  // (the email when the account has no name). It opens the account menu.
392
473
  // People (owners) and Appearance live in the account menu.
@@ -890,10 +971,11 @@ function xeroImportSection(organisations) {
890
971
  // Once Xero is on: each organisation's connection, the history copy, and
891
972
  // Connect, which goes to Xero and comes back.
892
973
  function xeroConnection(organisations, canChange) {
893
- const host = h('div', { class: 'fx-settings' });
974
+ const host = h('div', { class: 'fx-xero-orgs' });
894
975
  for (const organisation of organisations) {
976
+ const usage = h('div');
895
977
  const row = settingRow({ label: organisation.name, hint: 'Checking…' });
896
- host.append(row);
978
+ host.append(h('div', { class: 'fx-xero-org' }, usage, row));
897
979
  (async () => {
898
980
  let hint;
899
981
  let control = null;
@@ -901,6 +983,7 @@ function xeroConnection(organisations, canChange) {
901
983
  let refused = false;
902
984
  try {
903
985
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
986
+ usage.replaceChildren(xeroUsage(status) ?? '');
904
987
  if (status.connected && status.calls?.stopped) {
905
988
  // A refused connection stopped calls (the call guard): say so, and
906
989
  // make Connect again the main button, which starts them again.
@@ -9,16 +9,19 @@
9
9
  // bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
10
10
  // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
11
11
  // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
12
- // long name, for the hover tip and the More menu. A count of 0 hides the
13
- // bar; Esc clears the ticks while it shows.
12
+ // long name, for the hover tip and the More menu. run(working) may call
13
+ // working('Sending') once the step really starts (after its dialog): the
14
+ // pressed button then shows a spinner and that word until run ends. A count
15
+ // of 0 hides the bar; Esc clears the ticks while it shows.
14
16
  import { lucide } from '/assets/ui/custom-fields.js';
17
+ import { applyPhoneRule } from './mobile.js';
15
18
 
16
19
  let barCount = 0;
17
20
 
18
21
  export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
19
22
  const { h } = ui;
20
23
  const id = `fx-bulk-more-${++barCount}`;
21
- const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
24
+ const element = applyPhoneRule(h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true }), 'bulkBar');
22
25
  const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
23
26
  let current = null;
24
27
  let busy = false;
@@ -27,14 +30,24 @@ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
27
30
  if (busy) return;
28
31
  busy = true;
29
32
  element.setAttribute('aria-busy', 'true');
30
- try { await action.run(); } finally {
33
+ // The step is on its way: its button (on the bar, or More's) spins.
34
+ const working = (word) => {
35
+ const pressed = [...element.querySelectorAll('.fx-bulk__act')].find((b) => b.dataset.action === action.label);
36
+ if (!pressed) return;
37
+ pressed.dataset.busy = '';
38
+ pressed.firstChild?.replaceWith(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }));
39
+ const text = pressed.querySelector('.fx-bulk__word');
40
+ if (text && word) text.textContent = `${word}…`;
41
+ };
42
+ try { await action.run(working); } finally {
31
43
  busy = false;
32
44
  element.removeAttribute('aria-busy');
45
+ if (current) draw(current);
33
46
  }
34
47
  };
35
48
 
36
49
  const button = (action) => h('button', {
37
- type: 'button',
50
+ type: 'button', 'data-action': action.label,
38
51
  class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
39
52
  ...(action.tone ? { 'data-tone': action.tone } : {}),
40
53
  ...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
@@ -25,17 +25,69 @@
25
25
  // column keeps its place: no drag, no Move, no Hide.
26
26
  // rows the <tbody> nodes (rowCells builds a row's cells)
27
27
  // lead <th> nodes before the columns (the tick column)
28
+ //
29
+ // Cells every list shares: dateCell (a short date), personCell (a person's
30
+ // initials) and fieldColumn (one of the lab's custom fields as a column, on
31
+ // Jobs and on Invoices, where it is the invoice's job's value).
28
32
  import { lucide } from '/assets/ui/custom-fields.js';
29
33
  import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
34
+ import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
35
+ import { initialsOf } from '/assets/ui/person.js';
36
+
37
+ const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
38
+ // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
39
+ // year when it is not this year).
40
+ export function shortDate(iso) {
41
+ if (!iso) return '—';
42
+ const at = new Date(`${iso}T00:00:00`);
43
+ const now = new Date();
44
+ const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
45
+ if (days === 0) return 'Today';
46
+ if (days === 1) return 'Tomorrow';
47
+ if (days === -1) return 'Yesterday';
48
+ return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
49
+ }
50
+
51
+ // A date cell: the short date, the whole date on hover.
52
+ export const dateCell = (h, iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
53
+
54
+ // A person as a badge (initials), the name on hover; `people` is
55
+ // [{ id, name }] (GET /api/custom-fields).
56
+ export function personCell(h, personId, people) {
57
+ if (!personId) return h('span', { class: 'fx-dim' }, '—');
58
+ const name = people.find((p) => p.id === personId)?.name ?? 'Former person';
59
+ return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
60
+ }
61
+
62
+ // A custom field's value as text.
63
+ export function fieldText(field, value, people) {
64
+ if (value == null || value === false) return '—';
65
+ if (field.type === 'checkbox') return 'Yes';
66
+ if (field.type === 'person') return people.find((p) => p.id === value)?.name ?? 'Former person';
67
+ if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
68
+ if (field.type === 'date') return shortDate(value);
69
+ return String(value);
70
+ }
71
+
72
+ // One custom field as a list column: `valueOf(row)` is the row's value
73
+ // (null when it has none), `people` names a person field's value.
74
+ export function fieldColumn(h, field, { id, valueOf, people }) {
75
+ const cell = field.type === 'person' ? (row) => personCell(h, valueOf(row), people)
76
+ : field.type === 'date' ? (row) => dateCell(h, valueOf(row)) : (row) => fieldText(field, valueOf(row), people);
77
+ const kind = field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text';
78
+ return { id, label: field.name, kind, num: field.type === 'number', className: 'fx-dim', cell };
79
+ }
30
80
 
31
81
 
32
82
  // A row's cells for the shown columns: a class per kind, the label for
33
83
  // the phone layout, the full text as the cell's name on hover.
34
84
  export function rowCells(h, columns, row) {
35
- return columns.map((c) => {
85
+ return assignPhoneSlots(columns).map((c) => {
86
+ const slot = phoneSlot(c);
36
87
  const td = h('td', {
37
88
  class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
38
89
  'data-label': c.label,
90
+ ...(slot ? { 'data-phone-slot': slot } : {}),
39
91
  }, c.cell(row));
40
92
  // A cell stays on one line and ends in "…" when the column is narrow
41
93
  // (flow.css, .fx-table--sized); the whole text shows on hover.
@@ -124,7 +176,7 @@ export function columnTable(h, config) {
124
176
  h('colgroup', {}, leadCols, cols, endCol),
125
177
  h('thead', {}, h('tr', {}, lead, heads, tail)),
126
178
  rows);
127
- const box = h('div', { class: 'fx-table-scroll' }, node);
179
+ const box = applyPhoneRule(h('div', { class: 'fx-table-scroll' }, node), 'columnTable');
128
180
  // The width the columns need; past the window the table scrolls
129
181
  // sideways. A browser shares spare width among fixed columns, so the
130
182
  // end column is sized here, and again when the window changes size.
@@ -15,6 +15,21 @@
15
15
 
16
16
  import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
17
17
  import { sidecar } from '/assets/ui/sidecar.js';
18
+ import { assignPhoneSlots, phoneSlot } from './mobile.js';
19
+
20
+ // The manager row uses the same phone slots as a column list.
21
+ const FIELD_SLOTS = assignPhoneSlots([
22
+ { id: 'name', kind: 'text' },
23
+ { id: 'type', kind: 'text' },
24
+ { id: 'by', kind: 'person' },
25
+ { id: 'date', kind: 'date' },
26
+ { id: 'where', kind: 'text' },
27
+ { id: 'menu', kind: 'text', phone: 'end' },
28
+ ]);
29
+ const fieldSlot = (id) => {
30
+ const slot = phoneSlot(FIELD_SLOTS.find((column) => column.id === id));
31
+ return slot ? { 'data-phone-slot': slot } : {};
32
+ };
18
33
 
19
34
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
20
35
  const TYPE_LABELS = {
@@ -395,12 +410,12 @@ export async function renderCustomFields(main, ui) {
395
410
  });
396
411
  input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
397
412
  queueMicrotask(() => { input.focus(); input.select(); });
398
- return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
413
+ return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') }, input);
399
414
  }
400
415
  const editButton = field.archived || disabled || field.source ? null
401
416
  : h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
402
417
  const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
403
- return h('div', { class: 'fx-cfm__name', role: 'cell' },
418
+ return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') },
404
419
  h('div', { class: 'fx-cfm__title' },
405
420
  field.source ? lucide(h, 'lock', 'Read only') : null,
406
421
  h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
@@ -418,11 +433,11 @@ export async function renderCustomFields(main, ui) {
418
433
  });
419
434
  return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
420
435
  nameCell(field),
421
- h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
422
- h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
423
- h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
424
- h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
425
- h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
436
+ h('div', { class: 'fx-cfm__type', role: 'cell', ...fieldSlot('type') }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
437
+ h('div', { class: 'fx-cfm__by', role: 'cell', ...fieldSlot('by') }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
438
+ h('div', { class: 'fx-cfm__date', role: 'cell', ...fieldSlot('date'), title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
439
+ h('div', { class: 'fx-cfm__where', role: 'cell', ...fieldSlot('where') }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
440
+ h('div', { class: 'fx-cfm__end', role: 'cell', ...fieldSlot('menu') }, field.source ? null : rowMenu(field, picker)));
426
441
  }
427
442
 
428
443
  // One group: a header that folds it, its rows, and "Create … field". A