@arcforgelabs/flow 2026.10.46 → 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.
@@ -9,13 +9,17 @@ import { scrubSpan } from '../../ui/monitoring.js';
9
9
  // the host setting SENTRY_DSN. It has no tables.
10
10
 
11
11
  // The browser SDK, loaded from Sentry's CDN only while this adapter is on
12
- // (server/ui/monitoring.js). Pinned, with its integrity hash. With traces on,
13
- // the bundle that also traces; Sentry ships it only with session replay in
14
- // it too, which Flow never turns on.
12
+ // (server/ui/monitoring.js). Pinned, with its integrity hash. With replay on,
13
+ // the bundle with session replay; with traces on, the bundle that also traces
14
+ // (Sentry ships it only with replay in it).
15
15
  export const SENTRY_BROWSER = Object.freeze({
16
16
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
17
17
  integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
18
18
  });
19
+ export const SENTRY_BROWSER_REPLAY = Object.freeze({
20
+ src: 'https://browser.sentry-cdn.com/11.4.0/bundle.replay.feedback.min.js',
21
+ integrity: 'sha384-9Iyc17wHvpK0bqZe+cuAedQDHoWwenMrxXR+HgLLkQ3rHM4zQD+amLzBnmrQgowj',
22
+ });
19
23
  export const SENTRY_BROWSER_TRACING = Object.freeze({
20
24
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
21
25
  integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
@@ -44,6 +48,7 @@ export const SENTRY_SETUP = {
44
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
45
49
  { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
46
50
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
47
52
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
48
53
  ],
49
54
  };
@@ -56,6 +61,7 @@ export function readSentrySettings(env) {
56
61
  // labelled apart so they never read as the live app's.
57
62
  const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
58
63
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
64
+ const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
59
65
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
60
66
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
61
67
  if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
@@ -66,7 +72,7 @@ export function readSentrySettings(env) {
66
72
  // Every event says which product and, when set, which client it is from,
67
73
  // so one support desk can serve many installations.
68
74
  const tags = client ? { product: 'flow', client } : { product: 'flow' };
69
- return { dsn, environment, feedback, tracesSampleRate, tags };
75
+ return { dsn, environment, feedback, replay, tracesSampleRate, tags };
70
76
  }
71
77
 
72
78
  // No trace for the host's health check: it is not a person waiting.
@@ -108,7 +114,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
108
114
  ),
109
115
  beforeSendSpan: scrubSpan,
110
116
  });
111
- log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
117
+ log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; replay ${settings.replay ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
112
118
  },
113
119
  async stop() {
114
120
  if (settings) await sdk.close(2000);
@@ -133,15 +139,16 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
133
139
  // meant for browsers.
134
140
  browserMonitoring() {
135
141
  if (!settings) return null;
136
- const { tracesSampleRate } = settings;
142
+ const { tracesSampleRate, replay } = settings;
137
143
  return {
138
144
  dsn: settings.dsn,
139
145
  environment: settings.environment,
140
146
  release,
141
147
  feedback: settings.feedback,
148
+ replay,
142
149
  tags: settings.tags,
143
150
  tracesSampleRate,
144
- script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
151
+ script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : replay ? SENTRY_BROWSER_REPLAY : SENTRY_BROWSER,
145
152
  };
146
153
  },
147
154
  // Runs `work` in a span of the current trace (server/core/tracing.js).
@@ -33,8 +33,9 @@ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad =
33
33
  let action = null;
34
34
  if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
35
35
  else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
36
+ const line = first.moneyAtRiskCents > 0 ? `${first.title} ${money.format(first.moneyAtRiskCents / 100)} at risk.` : first.title;
36
37
  element.replaceChildren(...[
37
- h('p', { class: 'fx-alerts__text' },
38
+ h('p', { class: 'fx-alerts__text', title: line },
38
39
  first.title,
39
40
  first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
40
41
  action,
package/server/ui/app.js CHANGED
@@ -25,6 +25,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
25
25
  import { initialsOf, personLabel } from '/assets/ui/person.js';
26
26
  import { startTips } from '/assets/ui/tip.js';
27
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';
28
29
 
29
30
  const $ = (id) => document.getElementById(id);
30
31
 
@@ -143,19 +144,20 @@ function roleSelect(value, attrs = {}) {
143
144
 
144
145
  let me = null;
145
146
 
146
- // Where each view sits: the sidebar, in this order, daily work first (no
147
- // `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
148
- // icon); the account menu (`place: 'account'`). `sidebar: false` views open
149
- // 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).
150
152
  const VIEWS = {
151
153
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
152
154
  invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
153
155
  reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
154
156
  'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
155
157
  'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
156
- automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
157
- 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
158
- 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)) },
159
161
  people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
160
162
  // Not in the sidebar: "Manage account" in the account menu opens the same
161
163
  // Clerk profile. The page stays for links and Search.
@@ -301,11 +303,7 @@ async function route() {
301
303
  const [view, id] = currentView();
302
304
  const path = id ? `/${view}/${id}` : `/${view}`;
303
305
  if (location.pathname !== path) history.replaceState(null, '', path);
304
- for (const link of document.querySelectorAll('a[data-nav]')) {
305
- link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
306
- }
307
- // More shows as current while one of its pages is open.
308
- document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
306
+ markNavCurrent(view);
309
307
  main.replaceChildren();
310
308
  main.className = 'fx-main';
311
309
  document.title = `${VIEWS[view].label} · Flow`;
@@ -359,26 +357,108 @@ function renderSignedOut() {
359
357
  });
360
358
  }
361
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
+
362
451
  function renderShell() {
363
452
  document.body.dataset.state = 'signed-in';
364
453
  const nav = $('nav');
365
- const items = [];
366
- const more = [];
367
- for (const [name, view] of Object.entries(VIEWS)) {
368
- if (view.ownerOnly && me?.person.role !== 'owner') continue;
369
- if (view.sidebar === false || view.place === 'account') continue;
370
- if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
371
- 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)));
372
- }
373
- // The rest of the setup pages, behind More (the shared ⋯ menu).
374
- 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;
375
456
  Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
376
- items.push(moreButton, morePanel);
377
457
  // "Report a problem" sits at the foot of the menu, above the account chip,
378
458
  // while Sentry and its report form are on (monitoring.js).
379
459
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
380
460
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
381
- nav.replaceChildren(...items, report);
461
+ nav.replaceChildren(moreButton, panel, report);
382
462
  // Folded or narrow, the logo is the sidebar button: it shows the mark and
383
463
  // turns into the sidebar icon under the pointer or keyboard focus.
384
464
  const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
@@ -387,7 +467,7 @@ function renderShell() {
387
467
  sidebar.classList.add('fx-toggle__icon');
388
468
  $('menu-toggle').replaceChildren(mark, sidebar);
389
469
  $('menu-toggle').onclick = toggleMenu;
390
- drawMenu();
470
+ syncSidebar();
391
471
  // The sidebar foot shows who is signed in: photo or initial, then the name
392
472
  // (the email when the account has no name). It opens the account menu.
393
473
  // People (owners) and Appearance live in the account menu.
@@ -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 } : {}),
@@ -31,6 +31,7 @@
31
31
  // Jobs and on Invoices, where it is the invoice's job's value).
32
32
  import { lucide } from '/assets/ui/custom-fields.js';
33
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';
34
35
  import { initialsOf } from '/assets/ui/person.js';
35
36
 
36
37
  const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -81,10 +82,12 @@ export function fieldColumn(h, field, { id, valueOf, people }) {
81
82
  // A row's cells for the shown columns: a class per kind, the label for
82
83
  // the phone layout, the full text as the cell's name on hover.
83
84
  export function rowCells(h, columns, row) {
84
- return columns.map((c) => {
85
+ return assignPhoneSlots(columns).map((c) => {
86
+ const slot = phoneSlot(c);
85
87
  const td = h('td', {
86
88
  class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
87
89
  'data-label': c.label,
90
+ ...(slot ? { 'data-phone-slot': slot } : {}),
88
91
  }, c.cell(row));
89
92
  // A cell stays on one line and ends in "…" when the column is narrow
90
93
  // (flow.css, .fx-table--sized); the whole text shows on hover.
@@ -173,7 +176,7 @@ export function columnTable(h, config) {
173
176
  h('colgroup', {}, leadCols, cols, endCol),
174
177
  h('thead', {}, h('tr', {}, lead, heads, tail)),
175
178
  rows);
176
- const box = h('div', { class: 'fx-table-scroll' }, node);
179
+ const box = applyPhoneRule(h('div', { class: 'fx-table-scroll' }, node), 'columnTable');
177
180
  // The width the columns need; past the window the table scrolls
178
181
  // sideways. A browser shares spare width among fixed columns, so the
179
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