@arcforgelabs/flow 2026.10.46 → 2026.10.48

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 (63) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
@@ -4,6 +4,13 @@
4
4
  // GET /api/monitoring says so and gives the project's public DSN. The SDK
5
5
  // comes from Sentry's CDN, pinned and checked against its integrity hash.
6
6
  // With the integration off, nothing loads and there is no "Report a problem".
7
+ //
8
+ // Replay (SENTRY_REPLAY, on by default): the SDK keeps the last minute of the
9
+ // page in the browser's memory and sends it only with a report or a browser
10
+ // error, so a report shows the open menu or hover that the click on "Report a
11
+ // problem" closed. Flow shows customers' and patients' names, addresses and
12
+ // prices: a replay keeps the page's shape, the pointer and the clicks, never
13
+ // its words.
7
14
 
8
15
  function loadScript({ src, integrity }) {
9
16
  return new Promise((resolve, reject) => {
@@ -89,6 +96,36 @@ export function scrubSpan(span) {
89
96
  return span;
90
97
  }
91
98
 
99
+ // Elements a replay never shows: frames, canvases and embedded objects, and
100
+ // links that carry an address, a number or a query.
101
+ const REPLAY_BLOCK = ['iframe', 'canvas', 'embed', 'object', 'a[href^="mailto:" i]', 'a[href^="tel:" i]', 'a[href^="sms:" i]', 'a[href*="?"]'];
102
+ // Attributes that can carry a name; masked like the text.
103
+ const REPLAY_MASKED_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'aria-description', 'alt', 'download', 'value', 'data-value'];
104
+ // What a replay keeps of a clicked element.
105
+ const KEPT_NODE_ATTRIBUTES = new Set(['id', 'class', 'role']);
106
+ const withoutQuery = (value) => (typeof value === 'string' ? value.replace(/[?#].*$/, '') : value);
107
+
108
+ // One event of a replay's recording, before it is kept. A click names its
109
+ // element by CSS path, and the path can quote an attribute's value: the
110
+ // values go, and of the element only its id, class and role stay. A page's
111
+ // address keeps its path, never its query string.
112
+ export function scrubRecordingEvent(event) {
113
+ const payload = event?.data?.payload;
114
+ if (!payload || typeof payload !== 'object') return event;
115
+ const next = { ...payload };
116
+ if (typeof next.category === 'string' && next.category.startsWith('ui.')) {
117
+ if (typeof next.message === 'string') next.message = next.message.replace(/\[[^\]]*\]/g, '');
118
+ const node = next.data?.node;
119
+ if (node) {
120
+ const attributes = Object.fromEntries(Object.entries(node.attributes ?? {}).filter(([name]) => KEPT_NODE_ATTRIBUTES.has(name)));
121
+ next.data = { ...next.data, node: { ...node, attributes, textContent: '' } };
122
+ }
123
+ }
124
+ if (next.category === 'navigation' && next.data) next.data = { ...next.data, from: withoutQuery(next.data.from), to: withoutQuery(next.data.to) };
125
+ if (typeof next.description === 'string') next.description = withoutQuery(next.description);
126
+ return { ...event, data: { ...event.data, payload: next } };
127
+ }
128
+
92
129
  /** Resolves to a function that opens the "Report a problem" form, or null. */
93
130
  export async function startMonitoring({ flow, person }) {
94
131
  const config = await flow('/api/monitoring');
@@ -113,6 +150,17 @@ export async function startMonitoring({ flow, person }) {
113
150
  submitButtonLabel: 'Send report',
114
151
  successMessageText: 'Thank you. Your report is sent.',
115
152
  })] : [];
153
+ if (config.replay && Sentry.replayIntegration) {
154
+ integrations.push(Sentry.replayIntegration({
155
+ maskAllText: true,
156
+ maskAllInputs: true,
157
+ blockAllMedia: true,
158
+ maskAttributes: REPLAY_MASKED_ATTRIBUTES,
159
+ block: REPLAY_BLOCK,
160
+ networkDetailAllowUrls: [],
161
+ beforeAddRecordingEvent: scrubRecordingEvent,
162
+ }));
163
+ }
116
164
  // Traces: page loads, moves between pages and each click's wait, with the
117
165
  // calls to Flow's server joined to the server's own trace. Only when the
118
166
  // installation set a share above 0 (SENTRY_TRACES_SAMPLE_RATE).
@@ -129,6 +177,9 @@ export async function startMonitoring({ flow, person }) {
129
177
  sendDefaultPii: false,
130
178
  integrations,
131
179
  tracesSampleRate,
180
+ // Replay: never a whole visit, only the minute before a report or error.
181
+ replaysSessionSampleRate: 0,
182
+ replaysOnErrorSampleRate: config.replay ? 1 : 0,
132
183
  beforeSendSpan: scrubSpan,
133
184
  initialScope: { tags: config.tags ?? {} },
134
185
  });
@@ -0,0 +1,44 @@
1
+ // Which sidebar pages a person has pinned. Stored with the account in
2
+ // people.views.nav (PUT /api/me/views/nav). No table of its own.
3
+ // Jobs and Invoices stay pinned: a request that leaves them out is saved
4
+ // with them put back. A page Flow does not know is dropped.
5
+
6
+ export const NAV_PAGES = Object.freeze([
7
+ 'jobs',
8
+ 'invoices',
9
+ 'contacts',
10
+ 'reconcile',
11
+ 'price-list',
12
+ 'invoicing-settings',
13
+ 'automations',
14
+ 'custom-fields',
15
+ 'integrations',
16
+ ]);
17
+
18
+ export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
19
+
20
+ // Today's sidebar, before a person has chosen: daily work (Jobs, Invoices,
21
+ // Contacts, Reconcile), Price list and Rules. Automations, Custom fields
22
+ // and Integrations start in More.
23
+ export const NAV_DEFAULT = Object.freeze([
24
+ 'jobs',
25
+ 'invoices',
26
+ 'contacts',
27
+ 'reconcile',
28
+ 'price-list',
29
+ 'invoicing-settings',
30
+ ]);
31
+
32
+ export function normaliseNavPins(input) {
33
+ const source = Array.isArray(input?.pinned) ? input.pinned : [];
34
+ const chosen = new Set(NAV_LOCKED);
35
+ for (const name of source) if (NAV_PAGES.includes(name)) chosen.add(name);
36
+ return { pinned: NAV_PAGES.filter((name) => chosen.has(name)) };
37
+ }
38
+
39
+ // No saved choice yet: the default sidebar. A saved choice is checked.
40
+ export function navPinsFromViews(input) {
41
+ const value = input && typeof input === 'object' ? input : {};
42
+ if (!value.nav) return { pinned: [...NAV_DEFAULT] };
43
+ return normaliseNavPins(value.nav);
44
+ }
@@ -1,13 +1,23 @@
1
- // The pages of a long list, in the list window's foot bar (listFrame), as
2
- // HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
3
- // previous and next. The server cuts the page (GET /api/invoicing/invoices
4
- // takes offset and limit), so a list works with any number of rows.
1
+ // Two pagers live here:
5
2
  //
6
- // listPager(h, { label, sizes, change }) answers { element, draw(page) }.
7
- // page = { offset, limit, total }. change({ offset, limit }) asks for a
8
- // page; a new size starts again at the first page. One page or none hides
9
- // the page numbers; the size stays, so a person can show more per page.
10
- // Issue #220 brings the pager over one record (.fx-pager) into this file.
3
+ // listPager — the pages of a long list, in the list window's foot bar
4
+ // (listFrame), as HubSpot's: rows per page, "1–100 of 1,204", and numbered
5
+ // pages between previous and next. The server cuts the page
6
+ // (GET /api/invoicing/invoices takes offset and limit), so a list works with
7
+ // any number of rows. listPager(h, { label, sizes, change }) answers
8
+ // { element, draw(page) }. page = { offset, limit, total }.
9
+ // change({ offset, limit }) asks for a page; a new size starts again at the
10
+ // first page. One page or none hides the page numbers; the size stays, so a
11
+ // person can show more per page.
12
+ //
13
+ // pager — ".fx-pager" over one record opened from a list: "← Back", "n of N",
14
+ // previous and next. pager(h, { label, back, … }) answers { element, draw }.
15
+ // draw({ count, prev, next, prevLabel, nextLabel }) fills the count and
16
+ // steps; omit draw (or draw with steps: false) for Back only, as when an
17
+ // invoice is not yet in the list. Invoices, Reconcile and Price list ›
18
+ // Reconcile use it (issue #220).
19
+
20
+ import { applyPhoneRule } from './mobile.js';
11
21
 
12
22
  export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
13
23
 
@@ -16,6 +26,43 @@ const PREV = chevron('M14.5 6l-6 6 6 6');
16
26
  const NEXT = chevron('M9.5 6l6 6-6 6');
17
27
  const count = new Intl.NumberFormat('en-AU');
18
28
 
29
+ // The bar over one record: Back, optional "n of N", previous and next.
30
+ export function pager(h, { label = 'Records', back }) {
31
+ const backBtn = h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back');
32
+ const element = h('nav', { class: 'fx-pager', 'aria-label': label }, backBtn);
33
+
34
+ function draw({
35
+ count: text = '',
36
+ prev = null,
37
+ next = null,
38
+ prevLabel = 'Previous',
39
+ nextLabel = 'Next',
40
+ steps = true,
41
+ } = {}) {
42
+ if (!steps) {
43
+ element.replaceChildren(backBtn);
44
+ return;
45
+ }
46
+ const step = (go, name, markup) => {
47
+ const button = h('button', {
48
+ class: 'fx-pager__step', type: 'button',
49
+ 'aria-label': name, title: name,
50
+ ...(!go ? { disabled: true } : { onclick: go }),
51
+ });
52
+ button.innerHTML = markup;
53
+ return button;
54
+ };
55
+ element.replaceChildren(
56
+ backBtn,
57
+ h('span', { class: 'fx-pager__count' }, text),
58
+ h('span', { class: 'fx-pager__steps' },
59
+ step(prev, prevLabel, PREV),
60
+ step(next, nextLabel, NEXT)));
61
+ }
62
+
63
+ return { element, draw };
64
+ }
65
+
19
66
  // The page numbers to show: the first, the last, and two each side of the
20
67
  // one shown, with '…' for a gap. Pages count from 1.
21
68
  export function pageNumbers(at, pages) {
@@ -34,7 +81,7 @@ export function pageRange({ offset, limit, total }) {
34
81
  }
35
82
 
36
83
  export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
37
- const element = h('nav', { class: 'fx-pages', 'aria-label': label });
84
+ const element = applyPhoneRule(h('nav', { class: 'fx-pages', 'aria-label': label }), 'listPager');
38
85
  const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
39
86
 
40
87
  function draw({ offset = 0, limit = sizes[0], total = 0 }) {
@@ -22,7 +22,7 @@ const LINE_PRICES = [
22
22
  // person's account; + shows a hidden one again.
23
23
  const COLUMN_DEFS = {
24
24
  code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
25
- name: { kind: 'client', cell: (item) => item.name },
25
+ name: { kind: 'client', phone: 'title', cell: (item) => item.name },
26
26
  price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
27
27
  tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
28
28
  };
@@ -13,6 +13,7 @@
13
13
  // The price check itself runs on each pipeline pass, so there is no button.
14
14
  import { bulkBar } from '/assets/ui/bulk-bar.js';
15
15
  import { lucide } from '/assets/ui/custom-fields.js';
16
+ import { pager as recordPager } from '/assets/ui/pager.js';
16
17
  import { findCodes, nameScore, reconcileRows, SAME_NAME } from '/assets/ui/price-match.js';
17
18
 
18
19
  const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
@@ -166,18 +167,17 @@ export async function renderPriceReconcile(main, ui) {
166
167
 
167
168
  // The invoice pager: Back to the price list, "n of N", previous and next.
168
169
  function pager() {
169
- const step = (to, label, path) => {
170
- const button = h('button', {
171
- class: 'fx-pager__step', type: 'button', disabled: to < 0 || to >= rows.length, 'aria-label': label, title: label,
172
- onclick: () => { at = to; pick = 0; chosen = null; drawCard(); },
173
- });
174
- button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
175
- return button;
176
- };
177
- return h('nav', { class: 'fx-pager', 'aria-label': 'Records to check' },
178
- h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/price-list') }, '← Back'),
179
- h('span', { class: 'fx-pager__count' }, `${at + 1} of ${rows.length}`),
180
- h('span', { class: 'fx-pager__steps' }, step(at - 1, 'Previous', 'M14.5 6l-6 6 6 6'), step(at + 1, 'Next', 'M9.5 6l6 6-6 6')));
170
+ const go = (to) => () => { at = to; pick = 0; chosen = null; drawCard(); };
171
+ const { element, draw } = recordPager(h, {
172
+ label: 'Records to check',
173
+ back: () => ui.go('/price-list'),
174
+ });
175
+ draw({
176
+ count: `${at + 1} of ${rows.length}`,
177
+ prev: at > 0 ? go(at - 1) : null,
178
+ next: at < rows.length - 1 ? go(at + 1) : null,
179
+ });
180
+ return element;
181
181
  }
182
182
 
183
183
  // One record (or one differing code) at a time.
@@ -22,6 +22,7 @@ import { statusRank } from '/assets/ui/job-board.js';
22
22
  import { filterJobs, KIND_OF_TYPE, normaliseListView, RECONCILE_COLUMNS } from '/assets/ui/job-view.js';
23
23
  import { normaliseReconcileView, RECONCILE_FILTER_FIELDS } from '/assets/ui/list-views.js';
24
24
  import { savedViews } from '/assets/ui/saved-views.js';
25
+ import { pager } from '/assets/ui/pager.js';
25
26
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
26
27
  import { formatCents } from '/assets/ui/invoicing.js';
27
28
 
@@ -95,7 +96,6 @@ function jobCandidateFacts(job) {
95
96
  }
96
97
 
97
98
 
98
- const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
99
99
  // The list's filter rows (view-bar.js), kept per viewer in this browser.
100
100
  // The list a person opened a gap from, for the pager.
101
101
  const FILTER_KEY = 'flow_reconcile_filters';
@@ -306,12 +306,12 @@ async function renderList(main, ui) {
306
306
  ? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
307
307
  : h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
308
308
  const ALL = {
309
- kind: { kind: 'icon', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
310
- client: { kind: 'client', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
311
- detail: { kind: 'text', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
312
- date: { kind: 'date', className: 'fx-dim', cell: (r) => shortDate(r.date) },
313
- total: { kind: 'total', num: true, cell: (r) => money(r.totalCents) },
314
- possible: { kind: 'status', className: 'fx-rec-list__mid', cell: possible },
309
+ kind: { kind: 'icon', phone: 'meta', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
310
+ client: { kind: 'client', phone: 'title', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
311
+ detail: { kind: 'text', phone: 'meta', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
312
+ date: { kind: 'date', phone: 'hide', className: 'fx-dim', cell: (r) => shortDate(r.date) },
313
+ total: { kind: 'total', phone: 'end', num: true, cell: (r) => money(r.totalCents) },
314
+ possible: { kind: 'status', phone: 'meta', className: 'fx-rec-list__mid', cell: possible },
315
315
  };
316
316
  const columns = state.columns.columns.map((id) => ({ id, label: RECONCILE_COLUMNS[id], ...ALL[id] }));
317
317
  host.replaceChildren(h('div', { class: 'fx-panel' }, heads.table({
@@ -351,15 +351,16 @@ async function renderGap(main, ui, id) {
351
351
  order = lists ? reconcileRows(lists).filter((r) => r.kind === kind).map(({ kind: k, id: i }) => ({ kind: k, id: i })) : [];
352
352
  }
353
353
  const at = order.findIndex((r) => r.id === id);
354
- const step = (to, label, path) => {
355
- const button = h('button', { class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label, onclick: () => ui.go(pathOf(to)) });
356
- button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
357
- return button;
358
- };
359
- const pager = h('nav', { class: 'fx-pager', 'aria-label': 'Reconcile' },
360
- h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/reconcile') }, '← Back'),
361
- h('span', { class: 'fx-pager__count' }, at >= 0 ? `${at + 1} of ${order.length}` : ''),
362
- h('span', { class: 'fx-pager__steps' }, step(at > 0 ? order[at - 1] : null, 'Previous', CHEVRONS.prev), step(at >= 0 ? order[at + 1] : null, 'Next', CHEVRONS.next)));
354
+ const { element: pagerBar, draw: drawPager } = pager(h, {
355
+ label: 'Reconcile',
356
+ back: () => ui.go('/reconcile'),
357
+ });
358
+ const goRow = (row) => (row ? () => ui.go(pathOf(row)) : null);
359
+ drawPager({
360
+ count: at >= 0 ? `${at + 1} of ${order.length}` : '',
361
+ prev: goRow(at > 0 ? order[at - 1] : null),
362
+ next: goRow(at >= 0 ? order[at + 1] : null),
363
+ });
363
364
  const next = () => {
364
365
  const rest = order.filter((r) => r.id !== id);
365
366
  writeStored(sessionStorage, NAV_KEY, { ...readStored(sessionStorage, NAV_KEY, {}), rows: rest });
@@ -380,7 +381,7 @@ async function renderGap(main, ui, id) {
380
381
  },
381
382
  });
382
383
  const card = h('section', { class: 'fx-rec fx-rec__gap', 'aria-label': 'Compare' },
383
- pager,
384
+ pagerBar,
384
385
  h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, title, sub), actions),
385
386
  // The search sits where it does on Jobs and Invoices: the right of the
386
387
  // toolbar over the rows it searches.
@@ -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', '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', 'person.js', 'pager.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', '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';
@@ -14,6 +14,7 @@
14
14
  import { lucide } from '/assets/ui/custom-fields.js';
15
15
  import { rowMenu } from '/assets/ui/kit.js';
16
16
  import { MAX_SAVED_VIEWS, normaliseSavedViews, sameView } from '/assets/ui/list-views.js';
17
+ import { applyPhoneRule } from './mobile.js';
17
18
 
18
19
  const ACTIVE_KEY = (page) => `flow_view_tab_${page}`;
19
20
 
@@ -33,7 +34,7 @@ export function savedViews(ui, { page, current, apply }) {
33
34
  const { h, flow, dialog, toast } = ui;
34
35
  let saved = normaliseSavedViews(page, ui.me?.views?.saved?.[page]);
35
36
  let active = null;
36
- const element = h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' });
37
+ const element = applyPhoneRule(h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' }), 'savedViews');
37
38
  const chosen = () => saved.list.find((v) => v.id === active) ?? null;
38
39
 
39
40
  async function store(next) {
@@ -1,9 +1,12 @@
1
1
  // The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
2
2
  // The Custom fields manager opens it over its table (`overlay`); a job's
3
- // panel keeps it as its right column, with tabs (Activity, Review). Closed,
4
- // it takes no room, so the main area gets the full width.
3
+ // panel keeps it as its right column, with tabs (Activity, Fix). Closed,
4
+ // it takes no room, so the main area gets the full width. `phone: 'page'`
5
+ // opens that panel as a full page on a phone: the tabs sit at the top of
6
+ // the record and the record bar (`recordBar`) carries back, the title and
7
+ // the next record. The main action stays in the caller's foot.
5
8
  //
6
- // sidecar(h, { label, overlay, escape, tabs, onChange }) answers
9
+ // sidecar(h, { label, overlay, escape, tabs, onChange, phone }) answers
7
10
  // { element, isOpen, open, show, close, tab, setTabs }:
8
11
  // open(id) selects tab `id` (or the first) and draws its `render()`
9
12
  // show(node) draws one node with no tabs (the overlay's own form)
@@ -13,14 +16,34 @@
13
16
  // `onChange(id)` hears each open (the tab id) and close (null).
14
17
 
15
18
  import { lucide } from '/assets/ui/custom-fields.js';
19
+ import { applyPhoneRule, isPhone, WIDTHS } from './mobile.js';
16
20
 
17
- export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
18
- const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
21
+ const CHEVRON_LEFT = 'M14.5 6l-6 6 6 6';
22
+ const CHEVRON_RIGHT = 'M9.5 6l6 6-6 6';
23
+
24
+ function glyph(button, path) {
25
+ button.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
26
+ return button;
27
+ }
28
+
29
+ export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null, phone = null } = {}) {
30
+ const element = applyPhoneRule(h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true }), 'sidecar');
19
31
  let list = tabs;
20
32
  let current = null;
33
+ let drawn = false;
34
+ if (phone === 'page') element.dataset.phone = 'page';
21
35
 
22
36
  const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
23
37
 
38
+ // On a phone page the tab strip is the record's top tabs, so it lives on
39
+ // the record (the parent) and stays put while the record scrolls. The
40
+ // body stays in the sidecar, which is what the phone suite measures.
41
+ function parkHead(head) {
42
+ const grid = element.parentElement;
43
+ if (element.classList.contains('fx-sidecar--page') && grid) grid.prepend(head);
44
+ else element.prepend(head);
45
+ }
46
+
24
47
  function drawTabs() {
25
48
  const head = h('header', { class: 'fx-sidecar__head' },
26
49
  h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
@@ -30,7 +53,10 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
30
53
  }, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
31
54
  closeButton());
32
55
  const tab = list.find((t) => t.id === current);
33
- element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
56
+ const body = h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null);
57
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
58
+ element.replaceChildren(body);
59
+ parkHead(head);
34
60
  }
35
61
 
36
62
  const api = {
@@ -41,18 +67,27 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
41
67
  current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
42
68
  drawTabs();
43
69
  element.hidden = false;
70
+ // The first open leaves the record where it is. A later tab brings
71
+ // its panel up under the sticky tabs.
72
+ if (drawn && element.classList.contains('fx-sidecar--page')) {
73
+ element.querySelector('.fx-sidecar__body')?.scrollIntoView({ block: 'start' });
74
+ }
75
+ drawn = true;
44
76
  onChange?.(current);
45
77
  },
46
78
  show(node) {
47
79
  current = null;
80
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
48
81
  element.replaceChildren(node);
49
82
  element.hidden = false;
50
83
  },
51
84
  close() {
52
85
  if (element.hidden) return;
53
86
  element.hidden = true;
87
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
54
88
  element.replaceChildren();
55
89
  current = null;
90
+ drawn = false;
56
91
  onChange?.(null);
57
92
  },
58
93
  // New tabs (a tab's data came in): an open tab draws again.
@@ -63,6 +98,15 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
63
98
  closeButton,
64
99
  };
65
100
 
101
+ if (phone === 'page') {
102
+ const sync = () => {
103
+ element.classList.toggle('fx-sidecar--page', isPhone());
104
+ if (!element.hidden && current) drawTabs();
105
+ };
106
+ sync();
107
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
108
+ }
109
+
66
110
  if (escape) {
67
111
  document.addEventListener('keydown', (event) => {
68
112
  if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
@@ -70,3 +114,59 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
70
114
  }
71
115
  return api;
72
116
  }
117
+
118
+ // The bar at the top of a record on a phone: ← back, the title, ‹ › for the
119
+ // previous and next record, and an optional ⋯ menu. Hidden above phone
120
+ // width (flow.css), so a desk record keeps its own bar. `prev` and `next`
121
+ // are { label, disabled, onclick }. `setSteps` and `setMenu` update them
122
+ // after the list arrives.
123
+ export function recordBar(h, { title = '', onBack, prev = null, next = null, menu = null, label = 'Record' } = {}) {
124
+ const back = glyph(h('button', { class: 'fx-record__back', type: 'button', 'aria-label': 'Back', title: 'Back', onclick: onBack }), CHEVRON_LEFT);
125
+ const titleNode = h('h2', { class: 'fx-record__title', title }, title);
126
+ const step = (path) => glyph(h('button', { class: 'fx-record__step', type: 'button' }), path);
127
+ const prevBtn = step(CHEVRON_LEFT);
128
+ const nextBtn = step(CHEVRON_RIGHT);
129
+ const end = h('div', { class: 'fx-record__end' }, prevBtn, nextBtn);
130
+ const apply = (button, spec, fallback) => {
131
+ const name = spec?.label ?? fallback;
132
+ button.setAttribute('aria-label', name);
133
+ button.title = name;
134
+ button.disabled = !spec || Boolean(spec.disabled);
135
+ button.onclick = spec?.onclick ?? null;
136
+ };
137
+ apply(prevBtn, prev, 'Previous');
138
+ apply(nextBtn, next, 'Next');
139
+ const element = h('header', { class: 'fx-record', 'aria-label': label }, back, titleNode, end);
140
+ element.setSteps = (nextPrev, nextNext) => {
141
+ apply(prevBtn, nextPrev, 'Previous');
142
+ apply(nextBtn, nextNext, 'Next');
143
+ };
144
+ element.setMenu = (node) => {
145
+ end.querySelector('.fx-record__menu')?.remove();
146
+ if (node) end.append(node);
147
+ };
148
+ if (menu) element.setMenu(menu);
149
+ return element;
150
+ }
151
+
152
+ // Keeps `pick()` in `foot` while the window is a phone, and back in `home`
153
+ // (the header's actions) otherwise. Returns the function to call again when
154
+ // the primary action changes (Save appears, a send arrives).
155
+ export function watchPhonePrimary(home, foot, pick) {
156
+ let parked = null;
157
+ const apply = () => {
158
+ const next = isPhone() ? (pick() ?? null) : null;
159
+ if (parked && parked !== next && home) home.append(parked);
160
+ parked = next;
161
+ if (parked) {
162
+ foot.hidden = false;
163
+ if (parked.parentElement !== foot) foot.replaceChildren(parked);
164
+ } else {
165
+ foot.replaceChildren();
166
+ foot.hidden = true;
167
+ }
168
+ };
169
+ apply();
170
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', apply);
171
+ return apply;
172
+ }
@@ -22,6 +22,7 @@
22
22
  // first the field a new row starts on
23
23
  // empty the words when no filter is on
24
24
  import { lucide } from '/assets/ui/custom-fields.js';
25
+ import { applyPhoneRule } from './mobile.js';
25
26
  import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
26
27
 
27
28
  const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
@@ -59,17 +60,22 @@ export function viewBar(h) {
59
60
  const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
60
61
  const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
61
62
  const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
62
- const wrap = h('span', { class: 'fx-chipset' }, button, clear);
63
+ const wrap = applyPhoneRule(h('span', { class: 'fx-chipset' }, button, clear), 'viewBar');
63
64
  let onClear = null;
64
65
  clear.addEventListener('click', () => onClear?.());
65
- const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
66
+ // Place again after the rows draw. A filter row is wider than the empty
67
+ // panel, and the panel is measured once it is open; without this a button
68
+ // at the right (Invoices) leaves the value and the bin off the screen.
69
+ const placeMenu = () => { if (panel.matches(':popover-open')) place(button, panel, 6); };
70
+ const fill = () => {
71
+ panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
72
+ placeMenu();
73
+ requestAnimationFrame(placeMenu);
74
+ };
66
75
  panel.addEventListener('toggle', (event) => {
67
76
  button.setAttribute('aria-expanded', String(event.newState === 'open'));
68
77
  if (event.newState !== 'open') return;
69
78
  fill();
70
- const at = button.getBoundingClientRect();
71
- panel.style.top = `${Math.round(at.bottom + 6)}px`;
72
- panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
73
79
  });
74
80
  return {
75
81
  node: [wrap, panel],
@@ -144,11 +150,16 @@ export function viewBar(h) {
144
150
  });
145
151
  return [button, pop];
146
152
  }
147
- const place = (button, pop) => {
148
- const at = button.getBoundingClientRect();
149
- pop.style.top = `${Math.round(at.bottom + 4)}px`;
150
- pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
151
- };
153
+ // Keep a popover on the screen: at the control's left edge, shifted left
154
+ // when it would run off the right. Called again after a filter row draws,
155
+ // because that row is wider than the empty panel.
156
+ function place(anchor, pop, gap = 4) {
157
+ const at = anchor.getBoundingClientRect();
158
+ const width = pop.offsetWidth;
159
+ const left = Math.max(8, Math.min(at.left, innerWidth - width - 8));
160
+ pop.style.top = `${Math.round(at.bottom + gap)}px`;
161
+ pop.style.left = `${Math.round(left)}px`;
162
+ }
152
163
 
153
164
  // ── Filters ──
154
165
  // As ClickUp's: groups of rows of field, operator and value, with a bin on
@@ -328,7 +339,7 @@ export function viewBar(h) {
328
339
  export function findBox(h, { label, value = '', oninput }) {
329
340
  const input = h('input', { class: 'fx-input fx-input--sm fx-find__input', type: 'search', placeholder: label, 'aria-label': label, value });
330
341
  const button = h('button', { type: 'button', class: 'fx-chip fx-find__button', 'aria-label': label, title: label }, lucide(h, 'search'));
331
- const element = h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input);
342
+ const element = applyPhoneRule(h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input), 'findBox');
332
343
  button.addEventListener('click', () => { element.classList.add('fx-find--open'); input.focus(); });
333
344
  input.addEventListener('input', () => oninput(input.value));
334
345
  input.addEventListener('blur', () => { if (!input.value) element.classList.remove('fx-find--open'); });