@arcforgelabs/flow 2026.10.42 → 2026.10.43

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/jobs.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
16
  import { lucide } from '/assets/ui/custom-fields.js';
17
17
  import { formatCents } from '/assets/ui/invoicing.js';
18
+ import { jobChangeReview } from '/assets/ui/job-change.js';
18
19
  import {
19
20
  caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
20
21
  } from '/assets/ui/job-board.js';
@@ -22,10 +23,14 @@ import {
22
23
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
23
24
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
24
25
  } from '/assets/ui/job-view.js';
26
+ import { sidecar } from '/assets/ui/sidecar.js';
25
27
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
26
28
 
27
29
  const LAYOUT_KEY = 'flow_jobs_layout';
28
30
  const FOLDED_KEY = 'flow_jobs_folded';
31
+ // The job panel's sidecar: the tab last open, or closed.
32
+ const SIDE_KEY = 'flow_job_side';
33
+ const SIDE_TABS = ['activity', 'review', 'closed'];
29
34
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
30
35
  // A job's events (server/core/ledger.js), in plain words.
31
36
  const ACTIVITY = {
@@ -85,6 +90,17 @@ function saveFolded(folded) {
85
90
  try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
86
91
  }
87
92
 
93
+ function readSide() {
94
+ try {
95
+ const value = localStorage.getItem(SIDE_KEY);
96
+ return SIDE_TABS.includes(value) ? value : 'activity';
97
+ } catch { return 'activity'; }
98
+ }
99
+
100
+ function saveSide(tab) {
101
+ try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
102
+ }
103
+
88
104
 
89
105
  const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
90
106
  const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
@@ -116,9 +132,8 @@ export async function renderJobs(main, ui) {
116
132
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
117
133
  // In the list, ticked rows get the same floating bar as Invoices.
118
134
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
119
- main.classList.add('fx-main--wide');
120
135
  main.append(
121
- pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
136
+ pageHeader('Jobs'),
122
137
  h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
123
138
  host,
124
139
  bulk.element);
@@ -1086,25 +1101,8 @@ export async function renderJobs(main, ui) {
1086
1101
  if (!o) return [];
1087
1102
  if (o.invoiceId) {
1088
1103
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
1089
- const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
1090
- // Only the job changed and the draft is not sent (ledger.js): one tap
1091
- // puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
1092
- if (!o.canUseNewLines) return [open];
1093
- const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1094
- return [h('button', {
1095
- class: 'fx-btn fx-btn--primary', type: 'button',
1096
- onclick: async (event) => {
1097
- event.currentTarget.disabled = true;
1098
- try {
1099
- await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
1100
- ui.toast(`The draft invoice now matches ${system}.`, 'success');
1101
- } catch (error) {
1102
- ui.toast(error.message, 'danger');
1103
- }
1104
- close();
1105
- await load();
1106
- },
1107
- }, `Adopt ${system} changes`), open];
1104
+ // A changed job is accepted in the panel's Review sidecar.
1105
+ return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
1108
1106
  }
1109
1107
  // Flow's own stage: a person marks a job ready to invoice, or an
1110
1108
  // automation does (Settings, Automations).
@@ -1147,15 +1145,20 @@ export async function renderJobs(main, ui) {
1147
1145
 
1148
1146
  // The job's page: a large panel over the board, like a task page. A
1149
1147
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1150
- // and its lines; its activity at the side (below on a phone). Esc, the X
1151
- // or a click outside closes it. `atFix` scrolls to what to fix. The job's
1152
- // custom fields can be changed here (in Flow only), and its stage moved
1153
- // (a hand-off to the work system).
1148
+ // and its lines. At the side (below on a phone), the sidecar: Activity,
1149
+ // and Review when the job changed after its draft (what changed, and
1150
+ // Accept change). The sidecar closes, and the main area takes its room.
1151
+ // Esc, the X or a click outside closes the panel. `atFix` scrolls to what
1152
+ // to fix, and opens Review when there is one. The job's custom fields can
1153
+ // be changed here (in Flow only), and its stage moved (a hand-off to the
1154
+ // work system).
1154
1155
  function openJob(job, { atFix = false } = {}) {
1155
1156
  const o = job.obligation;
1156
1157
  const stage = invoiceStage(o);
1157
1158
  const found = problemsOf(job);
1158
1159
  const choose = found.find((f) => f.go === 'choose') ?? null;
1160
+ // The job changed after its draft: the sidecar has a Review tab.
1161
+ const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
1159
1162
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1160
1163
  const status = job.status ?? NO_STATUS;
1161
1164
  const late = isLate(job);
@@ -1170,9 +1173,14 @@ export async function renderJobs(main, ui) {
1170
1173
  : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1171
1174
  : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1172
1175
  const rest = found.filter((f) => f.go !== 'choose');
1176
+ // One short line a fix: its label, what is wrong, and its link. What to
1177
+ // do is the link's tip.
1173
1178
  const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
1174
1179
  h('h3', {}, 'Fix'),
1175
- rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
1180
+ rest.map((f) => {
1181
+ const go = fixLink(f.go, f.todo);
1182
+ return h('div', { class: 'fx-task__fixrow', title: go ? null : f.todo }, h('strong', {}, f.label), h('span', {}, f.text), go);
1183
+ })) : null;
1176
1184
  // No invoice, and the match is unclear: one line and two buttons. The
1177
1185
  // possible matches are compared on the Reconcile page, not here.
1178
1186
  const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
@@ -1200,8 +1208,12 @@ export async function renderJobs(main, ui) {
1200
1208
  }
1201
1209
  // Each fix's link: Flow's own invoice, the job in the work system, or
1202
1210
  // the Flow page where the fix is made (job-board.js GO).
1203
- function fixLink(go) {
1204
- const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
1211
+ function fixLink(go, tip = null) {
1212
+ const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, title: tip, onclick: (event) => { close(); ui.go(event); } }, text);
1213
+ if (go === 'review' && reviewable) {
1214
+ return h('button', { class: 'fx-task__link fx-task__linkbtn', type: 'button', title: tip, onclick: () => side.open('review') }, 'Review');
1215
+ }
1216
+ if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1205
1217
  if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1206
1218
  if (go === 'work' && job.link?.url) {
1207
1219
  return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
@@ -1213,12 +1225,55 @@ export async function renderJobs(main, ui) {
1213
1225
  }
1214
1226
  const lines = job.lines ?? [];
1215
1227
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
1228
+ const activityBody = h('div', { class: 'fx-task__activity' },
1229
+ o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`));
1230
+ // Review: the job's change as the invoice page shows it (job-change.js),
1231
+ // read from the invoice when the sidecar first needs it.
1232
+ const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
1233
+ let reviewRead = false;
1234
+ const readReview = () => {
1235
+ if (reviewRead) return;
1236
+ reviewRead = true;
1237
+ flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
1238
+ .catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
1239
+ };
1240
+ function drawReview(invoice) {
1241
+ const change = invoice?.jobChange ?? null;
1242
+ reviewBody.replaceChildren(change
1243
+ ? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
1244
+ : h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
1245
+ }
1246
+ // After Accept change or a recheck: the board reads again, and this
1247
+ // job's panel opens again with its new facts.
1248
+ async function reopen() {
1249
+ await load();
1250
+ const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
1251
+ close();
1252
+ if (fresh) openJob(fresh);
1253
+ }
1254
+ const sideButton = h('button', {
1255
+ class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
1256
+ onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'review' && reviewable ? 'review' : 'activity')),
1257
+ }, lucide(h, 'panel-right'));
1258
+ const side = sidecar(h, {
1259
+ label: 'Job side panel',
1260
+ tabs: [
1261
+ { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1262
+ reviewable ? { id: 'review', label: 'Review', icon: 'git-compare', mark: true, render: () => { readReview(); return reviewBody; } } : null,
1263
+ ].filter(Boolean),
1264
+ onChange: (tab) => {
1265
+ saveSide(tab);
1266
+ sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
1267
+ },
1268
+ });
1269
+ side.element.classList.add('fx-task__side');
1216
1270
  element.append(h('div', { class: 'fx-task__grid' },
1217
1271
  h('div', { class: 'fx-task__main' },
1218
1272
  h('header', { class: 'fx-task__head' },
1219
1273
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
1220
1274
  h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
1221
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', onclick: close }, '✕')),
1275
+ h('div', { class: 'fx-task__tools' }, sideButton,
1276
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
1222
1277
  h('h2', { class: 'fx-task__title' }, clientOf(job)),
1223
1278
  h('div', { class: 'fx-task__stage' }, stagePill(status)),
1224
1279
  h('dl', { class: 'fx-task__fields' },
@@ -1255,13 +1310,14 @@ export async function renderJobs(main, ui) {
1255
1310
  h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
1256
1311
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1257
1312
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1258
- h('aside', { class: 'fx-task__activity', 'aria-label': 'Activity' },
1259
- h('h3', {}, 'Activity'),
1260
- o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
1313
+ side.element));
1261
1314
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1262
1315
  element.addEventListener('close', () => element.remove());
1263
1316
  document.body.append(element);
1264
1317
  element.showModal();
1318
+ const last = readSide();
1319
+ if (atFix && reviewable) side.open('review');
1320
+ else if (last !== 'closed') side.open(last === 'review' && reviewable ? 'review' : 'activity');
1265
1321
  if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
1266
1322
  if (!o) return;
1267
1323
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
@@ -99,6 +99,7 @@ export async function startMonitoring({ flow, person }) {
99
99
  integrations,
100
100
  tracesSampleRate,
101
101
  beforeSendSpan: scrubSpan,
102
+ initialScope: { tags: config.tags ?? {} },
102
103
  });
103
104
  Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
104
105
  // Who reported or met the error, so the lab can follow up.
@@ -29,7 +29,7 @@ export async function renderPriceList(main, ui) {
29
29
  const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
30
30
  const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
31
31
  const alerts = alertStrip(ui, 'price-list');
32
- main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [reconcileButton, addButton] }), alerts.element, host);
32
+ main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
33
33
 
34
34
  let data = null;
35
35
  let query = '';
@@ -176,8 +176,7 @@ export async function renderPriceList(main, ui) {
176
176
  table,
177
177
  ];
178
178
  const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
179
- linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r,
180
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
179
+ linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
181
180
  sections.push(settings);
182
181
  host.replaceChildren(...sections.filter(Boolean));
183
182
  // Opened from Reconcile's "Edit code": that code's dialog, once.
@@ -26,17 +26,16 @@ export async function renderPriceReconcile(main, ui) {
26
26
  const icon = (name, label) => lucide(h, name, label);
27
27
  let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
28
28
  const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
29
- const subtitle = h('span', {});
29
+ const tally = h('span', {});
30
30
  const toggle = (name, label, glyph) => h('button', {
31
31
  class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
32
32
  onclick: () => setView(name),
33
33
  }, icon(glyph));
34
34
  const bulk = bulkBar(ui, { label: 'Ticked records' });
35
35
  main.append(
36
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
37
- h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
36
+ ui.crumbs(['/price-list', 'Price list'], 'Reconcile'),
38
37
  ui.pageHeader('Reconcile prices', {
39
- subtitle,
38
+ count: tally,
40
39
  actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
41
40
  }),
42
41
  host, bulk.element);
@@ -129,13 +128,13 @@ export async function renderPriceReconcile(main, ui) {
129
128
  if (!data) return;
130
129
  bulk.draw({ count: 0 });
131
130
  if (!data.systems.length) {
132
- subtitle.replaceChildren('');
131
+ tally.replaceChildren('');
133
132
  host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
134
133
  h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
135
134
  return;
136
135
  }
137
136
  if (!rows.length) {
138
- subtitle.replaceChildren('');
137
+ tally.replaceChildren('');
139
138
  host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
140
139
  h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
141
140
  return;
@@ -184,7 +183,7 @@ export async function renderPriceReconcile(main, ui) {
184
183
  // One record (or one differing code) at a time.
185
184
  function drawCard() {
186
185
  const row = rows[at];
187
- subtitle.replaceChildren(`${rows.length} to check`);
186
+ tally.replaceChildren(`${rows.length} to check`);
188
187
  const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
189
188
  const record = row.record;
190
189
  const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
@@ -267,7 +266,7 @@ export async function renderPriceReconcile(main, ui) {
267
266
 
268
267
  // Every record and code to check; ticked rows go from the bulk bar.
269
268
  function drawList() {
270
- subtitle.replaceChildren(`${rows.length} to check`);
269
+ tally.replaceChildren(`${rows.length} to check`);
271
270
  const ticked = new Set();
272
271
  const boxes = new Map();
273
272
  const picked = () => rows.filter((r) => ticked.has(r.key));
@@ -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
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.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', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'flow-mark.svg']);
24
+ 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', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.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,72 @@
1
+ // The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
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.
5
+ //
6
+ // sidecar(h, { label, overlay, escape, tabs, onChange }) answers
7
+ // { element, isOpen, open, show, close, tab, setTabs }:
8
+ // open(id) selects tab `id` (or the first) and draws its `render()`
9
+ // show(node) draws one node with no tabs (the overlay's own form)
10
+ // close() hides it and empties it
11
+ // `tabs` is [{ id, label, icon, render }]; each tab is an icon and one word.
12
+ // `escape` closes it on Esc when no dialog or menu is open over the page.
13
+ // `onChange(id)` hears each open (the tab id) and close (null).
14
+
15
+ import { lucide } from '/assets/ui/custom-fields.js';
16
+
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 });
19
+ let list = tabs;
20
+ let current = null;
21
+
22
+ const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
23
+
24
+ function drawTabs() {
25
+ const head = h('header', { class: 'fx-sidecar__head' },
26
+ h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
27
+ list.map((t) => h('button', {
28
+ class: 'fx-sidecar__tab', type: 'button', role: 'tab', 'data-tab': t.id,
29
+ 'aria-selected': String(t.id === current), onclick: () => api.open(t.id),
30
+ }, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
31
+ closeButton());
32
+ const tab = list.find((t) => t.id === current);
33
+ element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
34
+ }
35
+
36
+ const api = {
37
+ element,
38
+ isOpen: () => !element.hidden,
39
+ tab: () => (element.hidden ? null : current),
40
+ open(id = null) {
41
+ current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
42
+ drawTabs();
43
+ element.hidden = false;
44
+ onChange?.(current);
45
+ },
46
+ show(node) {
47
+ current = null;
48
+ element.replaceChildren(node);
49
+ element.hidden = false;
50
+ },
51
+ close() {
52
+ if (element.hidden) return;
53
+ element.hidden = true;
54
+ element.replaceChildren();
55
+ current = null;
56
+ onChange?.(null);
57
+ },
58
+ // New tabs (a tab's data came in): an open tab draws again.
59
+ setTabs(next) {
60
+ list = next;
61
+ if (!element.hidden && current) drawTabs();
62
+ },
63
+ closeButton,
64
+ };
65
+
66
+ if (escape) {
67
+ document.addEventListener('keydown', (event) => {
68
+ if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
69
+ });
70
+ }
71
+ return api;
72
+ }
@@ -0,0 +1,99 @@
1
+ // Tips: every control that shows only an icon says its name on hover and on
2
+ // keyboard focus, in one small tip beside it. One listener on the document
3
+ // serves the whole app, so a new icon button needs only a name:
4
+ //
5
+ // data-tip="Clear" the tip, when it differs from the spoken name
6
+ // aria-label="Close" an icon-only control's name is its tip
7
+ // title="Search" taken over as the tip (no second, native tip)
8
+ //
9
+ // A control with words on it shows no tip. Clerk's own components keep their
10
+ // own tips. The tip floats on <body>, so a scrolling list cannot cut it off.
11
+
12
+ const INTERACTIVE = 'button, a[href], [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"], summary';
13
+ const CANDIDATE = `[data-tip], ${INTERACTIVE.split(', ').map((s) => `${s}[aria-label], ${s}[title]`).join(', ')}, [role="img"][aria-label], [role="img"][title]`;
14
+
15
+ // True when the element shows no word of its own: only icons or a symbol.
16
+ export function iconOnly(text) {
17
+ return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
18
+ }
19
+
20
+ // The tip for this element, or null. `el` needs getAttribute, textContent
21
+ // and closest (a DOM element, or a test's stand-in).
22
+ export function tipText(el) {
23
+ if (!el || el.closest?.('[class*="cl-"]')) return null;
24
+ if (!iconOnly(el.textContent) && !el.getAttribute('data-tip')) return null;
25
+ return el.getAttribute('title') || el.getAttribute('data-tip') || el.getAttribute('aria-label') || null;
26
+ }
27
+
28
+ // Marks a control icon-only: its spoken name and its tip. Returns the node.
29
+ export function named(node, label, tip = label) {
30
+ node.setAttribute('aria-label', label);
31
+ node.setAttribute('data-tip', tip);
32
+ node.removeAttribute('title');
33
+ return node;
34
+ }
35
+
36
+ let bubble = null;
37
+ let anchor = null;
38
+
39
+ function place() {
40
+ if (!bubble || !anchor) return;
41
+ const box = anchor.getBoundingClientRect();
42
+ const tip = bubble.getBoundingClientRect();
43
+ const gap = 6;
44
+ // A folded sidebar's icons take their tip at the right, as in ClickUp.
45
+ if (anchor.dataset.tipSide === 'right') {
46
+ bubble.style.transform = `translate(${Math.round(box.right + gap)}px, ${Math.round(box.top + box.height / 2 - tip.height / 2)}px)`;
47
+ return;
48
+ }
49
+ const above = box.top - tip.height - gap;
50
+ const top = above >= 4 ? above : box.bottom + gap;
51
+ const left = Math.min(Math.max(4, box.left + box.width / 2 - tip.width / 2), window.innerWidth - tip.width - 4);
52
+ bubble.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
53
+ }
54
+
55
+ function show(el) {
56
+ const text = tipText(el);
57
+ if (!text) return;
58
+ // A native title would show a second tip after a pause, so the tip takes
59
+ // it over (a title set again later wins).
60
+ if (el.hasAttribute('title')) {
61
+ if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.getAttribute('title'));
62
+ el.setAttribute('data-tip', el.getAttribute('title'));
63
+ el.removeAttribute('title');
64
+ return show(el);
65
+ }
66
+ if (!bubble) {
67
+ bubble = document.createElement('div');
68
+ bubble.className = 'fx-tip';
69
+ bubble.setAttribute('role', 'tooltip');
70
+ bubble.setAttribute('aria-hidden', 'true');
71
+ }
72
+ bubble.textContent = text;
73
+ anchor = el;
74
+ (el.closest('dialog[open]') ?? document.body).append(bubble);
75
+ place();
76
+ }
77
+
78
+ function hide(el) {
79
+ if (el && el !== anchor) return;
80
+ anchor = null;
81
+ bubble?.remove();
82
+ }
83
+
84
+ export function startTips() {
85
+ document.addEventListener('pointerover', (event) => {
86
+ if (event.pointerType === 'touch') return;
87
+ const el = event.target.closest?.(CANDIDATE);
88
+ if (el && el !== anchor) show(el);
89
+ else if (!el) hide();
90
+ });
91
+ document.addEventListener('focusin', (event) => {
92
+ const el = event.target.closest?.(CANDIDATE);
93
+ if (el && el.matches(':focus-visible')) show(el);
94
+ });
95
+ document.addEventListener('focusout', (event) => hide(event.target.closest?.(CANDIDATE)));
96
+ document.addEventListener('pointerdown', () => hide());
97
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hide(); });
98
+ window.addEventListener('scroll', () => hide(), true);
99
+ }
@@ -49,7 +49,7 @@ export async function renderXeroImport(main, ui) {
49
49
  const history = params.get('show') === 'all';
50
50
  const icon = (name, label) => lucide(h, name, label);
51
51
  const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
52
- const subtitle = h('span', {});
52
+ const tally = h('span', {});
53
53
  const actions = [];
54
54
  let view = params.get('view') === 'list' ? 'list' : 'card';
55
55
  if (!history) {
@@ -66,11 +66,8 @@ export async function renderXeroImport(main, ui) {
66
66
  renderReview();
67
67
  }
68
68
  main.append(
69
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
70
- h('a', { href: '/integrations', onclick: (event) => ui.go(event) }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'),
71
- h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
72
- h('span', {}, history ? 'History' : 'Review')),
73
- ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
69
+ ui.crumbs(['/integrations', 'Integrations'], ['/integrations/xero', 'Xero'], history ? 'History' : 'Review'),
70
+ ui.pageHeader(history ? 'Xero history' : 'Review', { count: tally, actions }),
74
71
  host);
75
72
 
76
73
  let organisationId;
@@ -117,7 +114,7 @@ export async function renderXeroImport(main, ui) {
117
114
  return;
118
115
  }
119
116
  if (!rows.length) {
120
- subtitle.replaceChildren('');
117
+ tally.replaceChildren('');
121
118
  host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
122
119
  return;
123
120
  }
@@ -134,7 +131,7 @@ export async function renderXeroImport(main, ui) {
134
131
  const fit = flowFit(row, pick);
135
132
  const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
136
133
  h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
137
- subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
134
+ tally.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
138
135
  const days = daysApart(row.issuedOn, fit.date);
139
136
  const facts = [
140
137
  ['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
@@ -195,7 +192,7 @@ export async function renderXeroImport(main, ui) {
195
192
 
196
193
  // Every one to check, best fit beside it; ticked rows go from the bulk bar.
197
194
  function reviewList(rows) {
198
- subtitle.replaceChildren(`${rows.length} to check`);
195
+ tally.replaceChildren(`${rows.length} to check`);
199
196
  const ticked = new Set();
200
197
  const boxes = new Map();
201
198
  const drawBar = () => {
@@ -315,7 +312,7 @@ export async function renderXeroImport(main, ui) {
315
312
  return;
316
313
  }
317
314
  const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
318
- subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
315
+ tally.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
319
316
  shown = offset === 0 ? page.rows : [...shown, ...page.rows];
320
317
  const rows = page.rows.map(rowOf);
321
318
  if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));