@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.
package/server/ui/jobs.js CHANGED
@@ -9,8 +9,8 @@
9
9
  // (docs/JOB_BOARD_MOVES.md). Grouped by a custom field, the move sets the
10
10
  // job's value, in Flow only. Grouped by status, the move is a stage move
11
11
  // in the work system: Seazona cannot take one through its API, so Flow
12
- // shows what to do there and the card waits in its new lane. On a phone,
13
- // or with a keyboard, the job's panel has the same moves.
12
+ // shows what to do there and the card waits in its new lane. On a phone a
13
+ // long press opens Move to. With a keyboard, the job's panel has the same moves.
14
14
 
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
16
  import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
@@ -18,15 +18,17 @@ import { lucide } from '/assets/ui/custom-fields.js';
18
18
  import { formatCents } from '/assets/ui/invoicing.js';
19
19
  import { jobChangeReview } from '/assets/ui/job-change.js';
20
20
  import {
21
- caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
21
+ caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, phoneLaneIndex, PHONE_HOLD_MS, problemSummary, statusRank,
22
22
  } from '/assets/ui/job-board.js';
23
+ import { rowMenu } from '/assets/ui/kit.js';
24
+ import { isPhone } from '/assets/ui/mobile.js';
23
25
  import {
24
26
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
25
27
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
26
28
  } from '/assets/ui/job-view.js';
27
29
  import { clipLabel } from '/assets/ui/person.js';
28
30
  import { savedViews } from '/assets/ui/saved-views.js';
29
- import { sidecar } from '/assets/ui/sidecar.js';
31
+ import { recordBar, sidecar } from '/assets/ui/sidecar.js';
30
32
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
31
33
 
32
34
  const LAYOUT_KEY = 'flow_jobs_layout';
@@ -38,7 +40,7 @@ const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
38
40
  // A job's events (server/core/ledger.js), in plain words.
39
41
  const ACTIVITY = {
40
42
  observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
41
- in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
43
+ in_work: 'Put back in work', drafted: 'Draft invoice made', draft_deleted: 'Draft invoice deleted', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
42
44
  accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
43
45
  held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
44
46
  status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
@@ -324,6 +326,7 @@ export async function renderJobs(main, ui) {
324
326
 
325
327
  function draw() {
326
328
  if (!state.jobs.length) {
329
+ boardTabs.hidden = true;
327
330
  main.classList.remove('fx-main--board');
328
331
  host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
329
332
  h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
@@ -339,6 +342,7 @@ export async function renderJobs(main, ui) {
339
342
  for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
340
343
  state.found = found;
341
344
  frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
345
+ if (state.view.layout !== 'board') boardTabs.hidden = true;
342
346
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
343
347
  drawBulk();
344
348
  }
@@ -389,7 +393,7 @@ export async function renderJobs(main, ui) {
389
393
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
390
394
  const card = h('article', {
391
395
  class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
392
- onclick: () => { if (!dragged) openJob(job); },
396
+ onclick: () => { if (dragged) { dragged = false; return; } openJob(job); },
393
397
  onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
394
398
  },
395
399
  h('div', { class: 'fx-job__client' }, clientOf(job)),
@@ -450,19 +454,108 @@ export async function renderJobs(main, ui) {
450
454
  const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
451
455
 
452
456
  function board(groups) {
453
- if (!groups.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
457
+ if (!groups.length) {
458
+ boardTabs.hidden = true;
459
+ return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
460
+ }
454
461
  const element = h('div', { class: 'fx-board' }, groups.map(({ label, jobs, rank, color }, index) => h('section', { class: 'fx-board__col', 'aria-label': label, 'data-lane': String(index) },
455
462
  h('header', { class: 'fx-board__head' },
456
463
  color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
457
464
  h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
458
465
  h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
459
466
  if (groups.some((g) => g.drop)) enableDrag(element, groups);
467
+ phoneLanes(element, groups);
460
468
  // Lanes past the frame's sides, and cards past a lane's foot, fade.
461
469
  edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
462
470
  element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
463
471
  return element;
464
472
  }
465
473
 
474
+ // Stage tabs for the phone: one per lane, with its count. A tap brings
475
+ // that lane across; a swipe updates the tab. Hidden on a wider screen.
476
+ const boardTabs = h('div', { class: 'fx-board-tabs', role: 'tablist', 'aria-label': 'Stages', hidden: true });
477
+ function phoneLanes(element, groups) {
478
+ const bar = main.querySelector('.fx-toolbar');
479
+ if (bar && !boardTabs.isConnected) bar.prepend(boardTabs);
480
+ const buttons = groups.map((group, index) => {
481
+ const count = group.jobs.length;
482
+ const name = `${group.label}, ${count} ${count === 1 ? 'job' : 'jobs'}`;
483
+ return h('button', {
484
+ type: 'button', role: 'tab', id: `fx-lane-tab-${index}`,
485
+ 'aria-controls': `fx-lane-${index}`, 'aria-selected': 'false', title: name,
486
+ onclick: () => element.scrollTo({ left: element.querySelector(`#fx-lane-${index}`)?.offsetLeft ?? 0, behavior: 'smooth' }),
487
+ }, h('span', {}, group.label), h('span', { class: 'fx-tabs__count' }, String(count)));
488
+ });
489
+ boardTabs.replaceChildren(...buttons);
490
+ boardTabs.hidden = false;
491
+ const cols = [...element.querySelectorAll(':scope > .fx-board__col')];
492
+ cols.forEach((col, index) => {
493
+ col.id = `fx-lane-${index}`;
494
+ col.setAttribute('role', 'tabpanel');
495
+ col.setAttribute('aria-labelledby', `fx-lane-tab-${index}`);
496
+ });
497
+ let shown = -1;
498
+ const mark = () => {
499
+ const at = phoneLaneIndex(element.scrollLeft, element.clientWidth, cols.length);
500
+ if (at === shown) return;
501
+ shown = at;
502
+ buttons.forEach((button, index) => {
503
+ const on = index === at;
504
+ button.setAttribute('aria-selected', String(on));
505
+ if (on) button.scrollIntoView({ inline: 'nearest', block: 'nearest' });
506
+ });
507
+ };
508
+ element.addEventListener('scroll', () => requestAnimationFrame(mark), { passive: true });
509
+ mark();
510
+ enableHold(element, groups);
511
+ }
512
+
513
+ // A long press on a phone opens Move to. A drag stays a mouse or pen
514
+ // move on a wider screen (enableDrag). A small move cancels the press,
515
+ // so a swipe still changes lane.
516
+ function enableHold(element, groups) {
517
+ element.addEventListener('pointerdown', (event) => {
518
+ if (!isPhone() || event.pointerType !== 'touch' || event.button !== 0) return;
519
+ const card = event.target.closest('.fx-job');
520
+ if (!card || event.target.closest('button, a')) return;
521
+ const startX = event.clientX;
522
+ const startY = event.clientY;
523
+ let held = false;
524
+ const timer = setTimeout(() => {
525
+ held = true;
526
+ dragged = true;
527
+ const from = Number(card.closest('.fx-board__col')?.dataset.lane);
528
+ openMoveSheet(cardJobs.get(card), groups, from);
529
+ }, PHONE_HOLD_MS);
530
+ // The release may land outside the card (a tap still ends on the page),
531
+ // so the press is watched on the document.
532
+ const stop = (end) => {
533
+ if (end.type === 'pointermove' && Math.hypot(end.clientX - startX, end.clientY - startY) <= 10) return;
534
+ clearTimeout(timer);
535
+ document.removeEventListener('pointermove', stop);
536
+ document.removeEventListener('pointerup', stop);
537
+ document.removeEventListener('pointercancel', stop);
538
+ if (held) setTimeout(() => { dragged = false; }, 0);
539
+ };
540
+ document.addEventListener('pointermove', stop);
541
+ document.addEventListener('pointerup', stop);
542
+ document.addEventListener('pointercancel', stop);
543
+ card.addEventListener('contextmenu', (menu) => { if (held) menu.preventDefault(); }, { once: true });
544
+ });
545
+ }
546
+
547
+ async function openMoveSheet(job, groups, from) {
548
+ const choices = groups.flatMap((group, index) => {
549
+ if (index === from || !group.drop) return [];
550
+ if (group.drop.kind !== 'field' && !movesStages(job)) return [];
551
+ return [{ label: group.label, value: String(index), detail: group.jobs.length }];
552
+ });
553
+ if (!choices.length || !job) return;
554
+ const picked = await ui.dialog({ title: 'Move to', choices });
555
+ const target = picked == null ? null : groups[Number(picked)];
556
+ if (target?.drop) moveJob(job, target.drop);
557
+ }
558
+
466
559
  // ── Moves ────────────────────────────────────────────────────────────────
467
560
  // Drag with a mouse or pen: press on a card, move 6px, and a copy of the
468
561
  // card follows the pointer. A lane that takes the card lights up. Touch
@@ -486,7 +579,7 @@ export async function renderJobs(main, ui) {
486
579
  drag = null;
487
580
  };
488
581
  element.addEventListener('pointerdown', (event) => {
489
- if (event.button !== 0 || event.pointerType === 'touch') return;
582
+ if (event.button !== 0 || event.pointerType === 'touch' || isPhone()) return;
490
583
  const card = event.target.closest('.fx-job');
491
584
  if (!card || event.target.closest('button, a')) return;
492
585
  const rect = card.getBoundingClientRect();
@@ -1091,12 +1184,15 @@ export async function renderJobs(main, ui) {
1091
1184
 
1092
1185
  // The job's page: a large panel over the board, like a task page. A
1093
1186
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1094
- // and its lines. At the side (below on a phone), the sidecar: Activity,
1095
- // and Fix when the job has a problem (for a changed job: what changed,
1096
- // and Accept change). The sidecar closes, and the main area takes its room.
1097
- // Esc, the X or a click outside closes the panel. `atFix` opens the
1098
- // Fix tab on the first problem (or scrolls to the invoice choice). The job's custom fields can
1099
- // be changed here (in Flow only), and its stage moved (a hand-off to the
1187
+ // and its lines. At the side, the sidecar: Activity, and Fix when the job
1188
+ // has a problem (for a changed job: what changed, and Accept change). The
1189
+ // sidecar closes, and the main area takes its room. On a phone the same
1190
+ // panel is the whole screen: back (the board stays where it was scrolled),
1191
+ // the client, the previous and next job, a menu, the sidecar's tabs under
1192
+ // the bar, and the main action fixed at the foot. Esc, the X or a click
1193
+ // outside closes the panel. `atFix` opens the Fix tab on the first problem
1194
+ // (or scrolls to the invoice choice). The job's custom fields can be
1195
+ // changed here (in Flow only), and its stage moved (a hand-off to the
1100
1196
  // work system).
1101
1197
  function openJob(job, { atFix = false } = {}) {
1102
1198
  const o = job.obligation;
@@ -1131,11 +1227,9 @@ export async function renderJobs(main, ui) {
1131
1227
  class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
1132
1228
  }, lucide(h, 'triangle-alert'), f.label));
1133
1229
  function toggleFix(i) {
1134
- if (picked === i && side.tab() === 'fix') { side.close(); return; }
1230
+ if (!isPhone() && picked === i && side.tab() === 'fix') { side.close(); return; }
1135
1231
  picked = i;
1136
1232
  side.open('fix');
1137
- // On a phone the sidecar is below the job: bring it into view.
1138
- if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
1139
1233
  }
1140
1234
  // No invoice, and the match is unclear: one line and two buttons. The
1141
1235
  // possible matches are compared on the Reconcile page, not here.
@@ -1221,6 +1315,7 @@ export async function renderJobs(main, ui) {
1221
1315
  }, lucide(h, 'panel-right'));
1222
1316
  const side = sidecar(h, {
1223
1317
  label: 'Job side panel',
1318
+ phone: 'page',
1224
1319
  tabs: [
1225
1320
  { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1226
1321
  hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
@@ -1233,7 +1328,40 @@ export async function renderJobs(main, ui) {
1233
1328
  },
1234
1329
  });
1235
1330
  side.element.classList.add('fx-task__side');
1236
- element.append(h('div', { class: 'fx-task__grid' },
1331
+ const jobs = state.found ?? [];
1332
+ const at = jobs.findIndex((item) => jobKey(item) === jobKey(job));
1333
+ // Back closes the page. The board is still underneath, at the same scroll.
1334
+ const stepTo = (delta) => {
1335
+ const other = jobs[at + delta];
1336
+ if (!other) return;
1337
+ const board = document.querySelector('.fx-board');
1338
+ const frame = document.querySelector('.fx-frame__body');
1339
+ const saved = { y: window.scrollY, frame: frame?.scrollTop ?? 0, board: board?.scrollLeft ?? 0 };
1340
+ close();
1341
+ openJob(other);
1342
+ const restore = () => {
1343
+ window.scrollTo(0, saved.y);
1344
+ const nextFrame = document.querySelector('.fx-frame__body');
1345
+ const nextBoard = document.querySelector('.fx-board');
1346
+ if (nextFrame) nextFrame.scrollTop = saved.frame;
1347
+ if (nextBoard) nextBoard.scrollLeft = saved.board;
1348
+ };
1349
+ restore();
1350
+ requestAnimationFrame(restore);
1351
+ };
1352
+ const menuItems = [];
1353
+ if (choose && o?.id) menuItems.push({ icon: 'git-compare', text: 'Reconcile', run: () => { close(); ui.go(`/reconcile/${o.id}`); } });
1354
+ if (job.link?.url) menuItems.push({ icon: 'external-link', text: `Open in ${system}`, run: () => window.open(job.link.url, '_blank', 'noopener') });
1355
+ const menu = menuItems.length ? rowMenu('More', menuItems, { className: 'fx-record__menu' }) : null;
1356
+ const bar = recordBar(h, {
1357
+ title: clientOf(job),
1358
+ label: 'Job',
1359
+ onBack: close,
1360
+ prev: { label: 'Previous job', disabled: at <= 0, onclick: () => stepTo(-1) },
1361
+ next: { label: 'Next job', disabled: at < 0 || at >= jobs.length - 1, onclick: () => stepTo(1) },
1362
+ menu: menu?.[0] ?? null,
1363
+ });
1364
+ element.append(bar, h('div', { class: 'fx-task__grid' },
1237
1365
  h('div', { class: 'fx-task__main' },
1238
1366
  h('header', { class: 'fx-task__head' },
1239
1367
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
@@ -1276,12 +1404,15 @@ export async function renderJobs(main, ui) {
1276
1404
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1277
1405
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1278
1406
  side.element));
1407
+ if (menu) element.append(menu[1]);
1279
1408
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1280
1409
  element.addEventListener('close', () => element.remove());
1281
1410
  document.body.append(element);
1282
1411
  element.showModal();
1283
1412
  const last = readSide();
1284
- if (atFix && hasFix) toggleFix(0);
1413
+ // On a phone the tabs are the page, so the sidecar stays open.
1414
+ if (isPhone()) side.open((atFix || last === 'fix') && hasFix ? 'fix' : 'activity');
1415
+ else if (atFix && hasFix) toggleFix(0);
1285
1416
  else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
1286
1417
  if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
1287
1418
  if (!o) return;
package/server/ui/kit.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // functions, so a card is the real thing. Reuse one or give it an option;
6
6
  // never copy it into a page (AGENTS.md, "UI: reuse first").
7
7
  import { lucide } from '/assets/ui/custom-fields.js';
8
+ import { applyPhoneRule } from './mobile.js';
8
9
  import { named } from '/assets/ui/tip.js';
9
10
 
10
11
  // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
@@ -53,12 +54,12 @@ export function holdButton(button, ms) {
53
54
  // `count` is a live number beside the name (2 to check); `help` is a docs
54
55
  // link, an icon with its name as a tip.
55
56
  export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
56
- return h('header', { class: 'fx-ph' },
57
+ return applyPhoneRule(h('header', { class: 'fx-ph' },
57
58
  h('div', { class: 'fx-ph__text' },
58
59
  h('h1', { class: 'fx-title' }, title),
59
60
  count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
60
61
  help ? helpLink(help) : null),
61
- actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
62
+ actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null), 'pageHeader');
62
63
  }
63
64
 
64
65
  // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
@@ -77,8 +78,8 @@ export function listHead(title, { count = null, help = null, actions = [], start
77
78
  const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
78
79
  const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
79
80
  const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
80
- const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
81
- h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
81
+ const bar = applyPhoneRule(h('div', { class: 'fx-listhead', 'data-sticky': sticky },
82
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions))), 'listHead');
82
83
  const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
83
84
  if (edge && typeof IntersectionObserver === 'function') {
84
85
  const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
@@ -99,7 +100,7 @@ export function listFrame(main, content) {
99
100
  const tools = h('div', { class: 'fx-frame__tools' });
100
101
  const body = h('div', { class: 'fx-frame__body' }, content);
101
102
  const view = h('div', { class: 'fx-frame__view' }, body);
102
- const element = h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools));
103
+ const element = applyPhoneRule(h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools)), 'listFrame');
103
104
  edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
104
105
  return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
105
106
  }
@@ -149,9 +150,9 @@ export function sectionTitle(text, help = null) {
149
150
  }
150
151
 
151
152
  export function notice(text, tone = 'info', title = null) {
152
- return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
153
+ return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
153
154
  title ? h('strong', {}, title) : null,
154
- h('span', {}, text));
155
+ h('span', {}, text)), 'notice');
155
156
  }
156
157
 
157
158
  // A field that fails a check glows red, like a required field, until the
@@ -159,37 +160,63 @@ export function notice(text, tone = 'info', title = null) {
159
160
  // `on` false clears it. Answers the field.
160
161
  export function fieldError(field, on = true) {
161
162
  if (!on) { field.removeAttribute('aria-invalid'); return field; }
163
+ applyPhoneRule(field, 'fieldError');
162
164
  field.setAttribute('aria-invalid', 'true');
163
165
  field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
164
166
  return field;
165
167
  }
166
168
 
167
- export function toast(text, tone = 'info') {
169
+ // A short result after an action. With `stay: true` it stays until the person
170
+ // closes it (for a danger a failed press must not pass unseen).
171
+ export function toast(text, tone = 'info', { stay = false } = {}) {
168
172
  const region = document.getElementById('toasts');
169
- const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
173
+ const item = applyPhoneRule(h('div', {
174
+ class: stay ? 'fx-toast fx-toast--stay' : 'fx-toast',
175
+ 'data-tone': tone,
176
+ role: tone === 'danger' ? 'alert' : 'status',
177
+ }, h('span', { class: 'fx-toast__text' }, text)), 'toast');
178
+ if (stay) {
179
+ item.append(h('button', {
180
+ type: 'button', class: 'fx-toast__close', 'aria-label': 'Close', title: 'Close',
181
+ onclick: () => item.remove(),
182
+ }, lucide(h, 'x')));
183
+ } else {
184
+ setTimeout(() => item.remove(), 4200);
185
+ }
170
186
  region.append(item);
171
- setTimeout(() => item.remove(), 4200);
172
187
  }
173
188
 
174
189
  // A modal built on <dialog>: resolves with the button's value, or null.
175
- export function dialog({ title, body, confirm, tone = 'primary' }) {
190
+ export function dialog({ title, body, confirm, tone = 'primary', choices = null }) {
176
191
  return new Promise((resolve) => {
192
+ const choiceList = choices
193
+ ? h('div', { class: 'fx-sheet__choices', role: 'group', 'aria-label': title },
194
+ choices.map((choice) => h('button', { class: 'fx-sheet__choice', type: 'submit', value: choice.value },
195
+ h('span', {}, choice.label),
196
+ choice.detail == null ? null : h('span', { class: 'fx-tabs__count' }, String(choice.detail)))))
197
+ : null;
198
+ const actions = choices ? null : h('div', { class: 'fx-dialog__actions' },
199
+ h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
200
+ h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm));
177
201
  const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
178
202
  h('h2', { class: 'fx-dialog__title' }, title),
179
- body,
180
- h('div', { class: 'fx-dialog__actions' },
181
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
182
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
183
- const element = h('dialog', { class: 'fx-dialog' }, form);
203
+ choiceList ?? body,
204
+ actions);
205
+ const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
184
206
  element.addEventListener('close', () => {
185
- const ok = element.returnValue === 'ok';
186
- const data = ok ? new FormData(form) : null;
207
+ const value = element.returnValue;
187
208
  element.remove();
188
- resolve(ok ? data : null);
209
+ if (choices) {
210
+ resolve(value && value !== 'cancel' ? value : null);
211
+ return;
212
+ }
213
+ const ok = value === 'ok';
214
+ resolve(ok ? new FormData(form) : null);
189
215
  });
216
+ if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
190
217
  document.body.append(element);
191
218
  element.showModal();
192
- element.querySelector('input, select, button[value="ok"]')?.focus();
219
+ element.querySelector(choices ? '.fx-sheet__choice' : 'input, select, button[value="ok"]')?.focus();
193
220
  });
194
221
  }
195
222
 
@@ -200,11 +227,11 @@ export function dialog({ title, body, confirm, tone = 'primary' }) {
200
227
  export function settingRow({ label, hint, control, wide = false, tone = null }) {
201
228
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
202
229
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
203
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
230
+ return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
204
231
  h('div', { class: 'fx-setting__text' },
205
232
  id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
206
233
  hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
207
- h('div', { class: 'fx-setting__control' }, control));
234
+ h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
208
235
  }
209
236
  settingRow.next = 0;
210
237
 
@@ -212,26 +239,126 @@ settingRow.next = 0;
212
239
 
213
240
  let rowMenus = 0;
214
241
 
242
+ function menuGlyph(icon) {
243
+ if (icon instanceof Node) return icon.isConnected ? icon.cloneNode(true) : icon;
244
+ return icon ? lucide(h, icon) : null;
245
+ }
246
+
215
247
  // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
216
248
  // button's right edge; `align: 'start'` lines it up with its left edge.
217
- // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
218
- export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
249
+ // `place: 'side'` floats it to the right of the button (the sidebar's More),
250
+ // and moves focus into the menu and back to the button on close.
251
+ // `trigger` swaps the ⋯ icon for the given contents.
252
+ // An item is `{ icon, text, run, tone }` or `'-'` (a line). `icon` is a Lucide
253
+ // name or a node. `trailing` is a control at the row's end (the pin) that
254
+ // does not close the menu: `{ icon, label, pressed, run, locked }`. `locked`
255
+ // draws the icon and does not take a click. `items` may be a function, read
256
+ // again each time the menu opens. `panel.refresh()` redraws it while open.
257
+ export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end', place = 'below' } = {}) {
219
258
  const id = `fx-row-menu-${++rowMenus}`;
220
- const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
221
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
222
- panel.addEventListener('toggle', (event) => {
223
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
224
- if (event.newState !== 'open') return;
225
- const close = () => panel.hidePopover();
226
- panel.replaceChildren(...items.map((entry) => (entry === '-'
227
- ? h('div', { class: 'fx-pop__sep' })
228
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
229
- h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
259
+ const button = applyPhoneRule(h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis')), 'rowMenu');
260
+ const panel = h('div', { class: place === 'side' ? 'fx-pop fx-pop--side' : 'fx-pop', id, popover: 'auto', role: 'menu' });
261
+ const entries = () => (typeof items === 'function' ? items() : items);
262
+ let picking = false;
263
+
264
+ function renderEntry(entry) {
265
+ if (entry === '-') return h('div', { class: 'fx-pop__sep' });
266
+ const item = h('button', {
267
+ type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone,
268
+ onclick: () => { picking = true; panel.hidePopover(); entry.run(); },
269
+ }, h('span', { class: 'fx-pop__tick' }, menuGlyph(entry.icon)), h('span', {}, entry.text));
270
+ if (!entry.trailing) return item;
271
+ const trail = entry.trailing;
272
+ const mark = menuGlyph(trail.icon);
273
+ const control = trail.locked
274
+ ? h('span', { class: 'fx-pop__pin fx-pop__pin--fixed', role: 'img', 'aria-label': trail.label }, mark)
275
+ : h('button', {
276
+ type: 'button', class: 'fx-pop__pin', 'aria-label': trail.label, 'aria-pressed': trail.pressed ? 'true' : 'false',
277
+ onclick: (event) => { event.stopPropagation(); trail.run(); },
278
+ }, mark);
279
+ return h('div', { class: 'fx-pop__row', role: 'none' }, item, control);
280
+ }
281
+
282
+ function placePanel() {
230
283
  const at = button.getBoundingClientRect();
284
+ const margin = 8;
285
+ const narrow = place === 'side' && window.matchMedia('(max-width: 820px)').matches;
286
+ if (narrow) {
287
+ // On a phone the sidebar is the slide-out. The menu stays inside it.
288
+ const drawer = button.closest('.fx-nav')?.getBoundingClientRect();
289
+ const left = Math.round((drawer?.left ?? at.left) + margin);
290
+ const width = Math.max(160, Math.round((drawer?.width ?? at.width) - margin * 2));
291
+ panel.style.width = `${width}px`;
292
+ panel.style.minWidth = '0';
293
+ let top = Math.round(at.bottom + 6);
294
+ if (top + panel.offsetHeight > innerHeight - margin) top = Math.max(margin, Math.round(innerHeight - margin - panel.offsetHeight));
295
+ panel.style.top = `${top}px`;
296
+ panel.style.left = `${left}px`;
297
+ return;
298
+ }
299
+ panel.style.width = '';
300
+ panel.style.minWidth = '';
301
+ if (place === 'side') {
302
+ const gap = 8;
303
+ let left = Math.round(at.right + gap);
304
+ if (left + panel.offsetWidth > innerWidth - margin) left = Math.round(at.left - gap - panel.offsetWidth);
305
+ panel.style.left = `${Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin))}px`;
306
+ let top = Math.round(at.top);
307
+ panel.style.top = `${Math.max(margin, Math.min(top, innerHeight - panel.offsetHeight - margin))}px`;
308
+ return;
309
+ }
231
310
  panel.style.top = `${Math.round(at.bottom + 6)}px`;
232
311
  const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
233
- panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
312
+ panel.style.left = `${Math.round(Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin)))}px`;
313
+ }
314
+
315
+ function draw(keepFocus) {
316
+ const active = document.activeElement;
317
+ const row = active?.closest?.('.fx-pop__row');
318
+ const text = row?.querySelector('[role="menuitem"]')?.textContent?.trim() ?? '';
319
+ const onPin = Boolean(active?.classList?.contains('fx-pop__pin'));
320
+ panel.replaceChildren(...entries().map(renderEntry));
321
+ if (!keepFocus || !text) return;
322
+ const next = [...panel.querySelectorAll('.fx-pop__row')].find((candidate) => candidate.querySelector('[role="menuitem"]')?.textContent?.trim() === text);
323
+ (onPin ? next?.querySelector('.fx-pop__pin') : next?.querySelector('[role="menuitem"]'))?.focus();
324
+ }
325
+
326
+ panel.refresh = () => {
327
+ if (!panel.matches(':popover-open')) return;
328
+ draw(true);
329
+ placePanel();
330
+ };
331
+
332
+ panel.addEventListener('toggle', (event) => {
333
+ const open = event.newState === 'open';
334
+ button.setAttribute('aria-expanded', String(open));
335
+ if (!open) {
336
+ // A chosen row moves focus itself. Anything else (Escape, a click
337
+ // outside) puts focus back on the button that opened the menu.
338
+ if (place === 'side' && !picking) button.focus();
339
+ picking = false;
340
+ return;
341
+ }
342
+ draw(false);
343
+ placePanel();
344
+ if (place === 'side') panel.querySelector('[role="menuitem"]')?.focus();
234
345
  });
346
+
347
+ if (place === 'side') {
348
+ panel.addEventListener('keydown', (event) => {
349
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
350
+ const stops = [...panel.querySelectorAll('button')];
351
+ if (!stops.length) return;
352
+ event.preventDefault();
353
+ const index = Math.max(0, stops.indexOf(document.activeElement));
354
+ const next = event.key === 'Home' ? 0
355
+ : event.key === 'End' ? stops.length - 1
356
+ : event.key === 'ArrowDown' ? (index + 1) % stops.length
357
+ : (index - 1 + stops.length) % stops.length;
358
+ stops[next].focus();
359
+ });
360
+ }
361
+
235
362
  return [button, panel];
236
363
  }
237
364
 
@@ -255,31 +382,59 @@ export function tileCard({ href, mark, title, status, blurb, meta, action = 'Man
255
382
  const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
256
383
  const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
257
384
  if (row) {
258
- return h('a', { class: 'fx-tile', href, onclick: inPlace },
385
+ return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
259
386
  logo,
260
387
  h('h3', {}, title),
261
- pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
388
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null)), 'tileCard');
262
389
  }
263
- return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
390
+ return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
264
391
  h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
265
392
  blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
266
- h('span', { class: 'fx-int-card__btn' }, action));
393
+ h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
267
394
  }
268
395
 
269
396
  // A titled group of one-line cards, three columns where they fit, with "Show all" at the right
270
397
  // when `href` is given (Claude's Customize page).
271
398
  export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
272
- return h('section', { class: 'fx-tiles' },
399
+ return applyPhoneRule(h('section', { class: 'fx-tiles' },
273
400
  h('header', { class: 'fx-tiles__head' },
274
401
  h('h2', {}, title),
275
402
  href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
276
- h('div', { class: 'fx-tiles__grid' }, cards));
403
+ h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
404
+ }
405
+
406
+ // A dialog that fills the screen has no pixel outside it, so an accessibility
407
+ // check cannot tell it is modal and reads the page underneath. While one is
408
+ // open, every other top-level node is inert. The attribute is what the check
409
+ // reads; the browser's own modal behaviour is unchanged.
410
+ function shieldBehindDialogs() {
411
+ const top = [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
412
+ for (const child of document.body.children) {
413
+ if (top && child !== top) child.setAttribute('inert', '');
414
+ else child.removeAttribute('inert');
415
+ }
416
+ }
417
+ // Node tests import this module and have no HTMLDialogElement. The wrap only
418
+ // matters in the browser, where showModal and close are the three call sites.
419
+ if (typeof HTMLDialogElement !== 'undefined') {
420
+ const openDialog = HTMLDialogElement.prototype.showModal;
421
+ const closeDialog = HTMLDialogElement.prototype.close;
422
+ HTMLDialogElement.prototype.showModal = function showModal() {
423
+ const result = openDialog.call(this);
424
+ shieldBehindDialogs();
425
+ return result;
426
+ };
427
+ HTMLDialogElement.prototype.close = function close(...args) {
428
+ const result = closeDialog.apply(this, args);
429
+ shieldBehindDialogs();
430
+ return result;
431
+ };
277
432
  }
278
433
 
279
434
  // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
280
435
  export function crumbs(...trail) {
281
436
  const here = trail.pop();
282
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
437
+ return applyPhoneRule(h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
283
438
  ...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
284
- h('span', {}, here));
439
+ h('span', {}, here)), 'crumbs');
285
440
  }