@arcforgelabs/flow 2026.10.48 → 2026.10.49

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.
@@ -205,13 +205,20 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
205
205
 
206
206
  /* ── Notices, pills, tags, toasts ──────────────────────────────────────── */
207
207
  .fx-notice {
208
- display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 14px;
208
+ display: block; padding: 12px 14px;
209
209
  border: 1px solid var(--t-accent-brd); border-radius: var(--t-radius);
210
210
  background: var(--t-accent-bg); color: var(--t-text); font-size: 14px;
211
211
  }
212
212
  .fx-notice[data-tone="warn"] { border-color: var(--t-warn-brd); background: var(--t-warn-bg); }
213
213
  .fx-notice[data-tone="danger"] { border-color: var(--t-danger-brd); background: var(--t-danger-bg); color: var(--t-danger); }
214
214
  .fx-panel > .fx-notice { margin: 12px; }
215
+ /* The title leads the text on the same line ("Problem. No Xero contact…"). */
216
+ .fx-notice > strong { margin-right: 6px; }
217
+ .fx-notice__short { display: none; }
218
+ @media (max-width: 639px) {
219
+ .fx-notice__short { display: inline; }
220
+ .fx-notice .fx-notice__long { display: none; }
221
+ }
215
222
 
216
223
  /* The job changed after its invoice was made (job-change.js): was / now per
217
224
  changed fact. Was is struck through; Now is the work system's value. */
@@ -371,24 +378,27 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
371
378
  .fx-logo--lg { width: 52px; height: 52px; }
372
379
  .fx-logo--lg img { width: 34px; height: 34px; }
373
380
 
374
- .fx-logo--xl { width: 112px; height: 112px; border-radius: 22px; }
375
- .fx-logo--xl img { width: 68px; height: 68px; }
376
381
  .fx-chip--static { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; font-size: 12.5px; cursor: default; }
377
382
 
378
383
  /* One integration's page: hero, then the setup beside its facts. */
379
384
  .fx-int-page { display: grid; gap: 24px; }
380
- .fx-int-hero { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 24px; }
381
- .fx-int-hero__text { display: grid; gap: 8px; flex: 1; min-width: 260px; }
382
- .fx-int-hero__text .fx-title { font-size: 34px; }
383
- .fx-int-hero__maker { display: flex; gap: 10px; align-items: baseline; color: var(--t-strong); font-weight: 650; }
384
- .fx-int-hero__blurb { max-width: 64ch; color: var(--t-text); font-size: 15.5px; }
385
- .fx-int-hero__actions { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 6px; }
385
+ .fx-int-hero { display: flex; align-items: center; gap: 14px; min-width: 0; }
386
+ .fx-int-hero .fx-title { min-width: 0; font-size: 28px; overflow-wrap: anywhere; }
387
+ .fx-int-hero__actions { position: relative; display: flex; align-items: center; gap: 10px; margin-left: auto; }
388
+ .fx-int-hero__actions .fx-progress { left: auto; right: 0; }
386
389
  .fx-int-page__grid { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 32px; align-items: start; }
387
390
  .fx-int-page__main { display: grid; gap: 28px; }
388
391
  .fx-int-page__main > .fx-alerts { margin: 0; }
389
392
  .fx-int-section { display: grid; gap: 14px; padding-top: 20px; border-top: 1px solid var(--fx-line); }
390
393
  .fx-int-section__title { color: var(--t-strong); font: 650 17px/1.3 var(--t-body); }
391
394
  .fx-int-section .fx-btn { justify-self: start; }
395
+ /* A folded section (Set up, Settings): a person opens it. Its chevron
396
+ points right when closed and down when open, as the table folds do. */
397
+ .fx-int-fold > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; margin: -10px 0; cursor: pointer; list-style: none; }
398
+ .fx-int-fold > summary::-webkit-details-marker { display: none; }
399
+ .fx-int-fold > summary::after { content: ''; width: 8px; height: 8px; margin-left: 2px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(-45deg); transition: transform 120ms var(--fx-ease); }
400
+ .fx-int-fold[open] > summary::after { transform: rotate(45deg) translateY(-2px); }
401
+ .fx-int-fold:not([open]) { gap: 0; }
392
402
  .fx-steps { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
393
403
  .fx-steps li { position: relative; padding-left: 38px; min-height: 26px; counter-increment: step; font-size: 14.5px; line-height: 1.55; }
394
404
  .fx-steps li::before {
@@ -419,8 +429,6 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
419
429
  @media (max-width: 900px) {
420
430
  .fx-int-page__grid { grid-template-columns: minmax(0, 1fr); }
421
431
  .fx-int-page__meta { position: static; }
422
- .fx-logo--xl { width: 72px; height: 72px; border-radius: 16px; }
423
- .fx-logo--xl img { width: 44px; height: 44px; }
424
432
  }
425
433
  .fx-drawer .fx-select { width: 100%; }
426
434
 
@@ -503,7 +511,7 @@ a.fx-strong:hover { text-decoration: underline; }
503
511
  .fx-crumbs { display: flex; gap: 8px; color: var(--t-dim); font-size: 13.5px; }
504
512
  .fx-crumbs a { color: var(--t-dim); text-decoration: none; }
505
513
  .fx-crumbs a:hover { color: var(--t-strong); text-decoration: underline; }
506
- .fx-invoice { display: grid; gap: 20px; }
514
+ .fx-invoice { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
507
515
  .fx-invoice__grid { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 20px; align-items: start; }
508
516
  .fx-invoice__main, .fx-invoice__aside { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; }
509
517
  .fx-invoice__aside:empty { display: none; }
@@ -558,6 +566,29 @@ a.fx-strong:hover { text-decoration: underline; }
558
566
  .fx-lines { overflow-x: auto; }
559
567
  .fx-lines__table { min-width: 680px; }
560
568
  }
569
+ /* CardRows (cardRows in kit.js): on a phone a table is a list of small
570
+ cards, never a sideways scroll. Each cell sits where the table's areas
571
+ place it, under its column name. */
572
+ @media (max-width: 639px) {
573
+ .fx-lines { overflow-x: visible; }
574
+ .fx-cardrows, .fx-cardrows tbody { display: block; width: 100%; min-width: 0; }
575
+ .fx-cardrows thead { display: none; }
576
+ .fx-cardrows tr {
577
+ display: grid; grid-template-columns: var(--fx-cardrows-columns); grid-template-areas: var(--fx-cardrows-areas);
578
+ gap: 10px 8px; padding: 14px 16px; border-bottom: 1px solid var(--fx-line-soft);
579
+ }
580
+ .fx-cardrows td,
581
+ .fx-cardrows td:first-child,
582
+ .fx-cardrows td:last-child,
583
+ .fx-lines__table--read td { display: grid; align-content: end; gap: 4px; width: auto; min-width: 0; padding: 0; border: 0; text-align: left; }
584
+ .fx-cardrows td[data-label]::before {
585
+ content: attr(data-label); color: var(--t-dim);
586
+ font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
587
+ }
588
+ .fx-cardrows td[data-end] { justify-items: end; text-align: right; }
589
+ .fx-cardrows :is(.fx-input, .fx-select, .fx-btn) { min-height: 44px; }
590
+ .fx-cardrows .fx-btn--icon { width: 44px; }
591
+ }
561
592
 
562
593
  /* ── Jobs board ─────────────────────────────────────────────────────────── */
563
594
  .fx-main > * { min-width: 0; }
@@ -1086,6 +1117,9 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1086
1117
  }
1087
1118
  .fx-record .fx-ph { flex: 1; min-width: 0; min-height: 0; gap: 8px; background: transparent; }
1088
1119
  .fx-record .fx-ph__text { min-width: 0; }
1120
+ /* A short number (FL-0001) shows whole; the status pill is compact beside it. */
1121
+ .fx-record .fx-ph__text .fx-status { flex: none; padding-inline: 8px; font-size: 12px; }
1122
+ .fx-record .fx-ph__text .fx-status::before { display: none; }
1089
1123
  .fx-record__end { display: flex; flex: none; align-items: center; }
1090
1124
  .fx-record__foot:not([hidden]) {
1091
1125
  display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 5; gap: 8px;
@@ -1204,18 +1238,25 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1204
1238
  .fx-setting + .fx-setting { border-top: 1px solid var(--fx-line-soft); }
1205
1239
  .fx-setting__text { display: grid; gap: 3px; min-width: 0; }
1206
1240
  .fx-setting__name { color: var(--t-strong); font: 500 14.5px/1.35 var(--t-body); }
1241
+ .fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
1242
+ .fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
1207
1243
  .fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
1208
1244
  .fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
1209
- .fx-setting__control { display: flex; justify-content: flex-end; min-width: 0; }
1245
+ .fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1210
1246
  .fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
1247
+ .fx-setting__control > .fx-input[type="number"] { width: 96px; }
1211
1248
  .fx-setting__control .fx-swatches { flex-wrap: nowrap; }
1212
1249
  .fx-setting__stack { display: grid; gap: 6px; justify-items: end; }
1213
1250
  .fx-setting__value { font-size: 12.5px; color: var(--t-strong); overflow-wrap: anywhere; text-align: right; }
1214
1251
  .fx-setting--wide { grid-template-columns: minmax(0, 1fr); }
1215
1252
  .fx-setting--wide .fx-setting__control { display: block; }
1216
1253
  @media (max-width: 639px) {
1217
- .fx-setting { grid-template-columns: minmax(0, 1fr); }
1218
- .fx-setting__control { justify-content: flex-start; }
1254
+ /* A wide control (a box, a menu, a choice) goes under its name. Switches
1255
+ and buttons stay at the right. */
1256
+ .fx-setting:has(> .fx-setting__control :is(.fx-select, .fx-multi, .fx-input:not([type="number"]), textarea, .fx-seg, .fx-swatches, .fx-setting__stack)),
1257
+ .fx-setting--wide { grid-template-columns: minmax(0, 1fr); }
1258
+ .fx-setting:has(> .fx-setting__control :is(.fx-select, .fx-multi, .fx-input:not([type="number"]), textarea, .fx-seg, .fx-swatches, .fx-setting__stack)) > .fx-setting__control { justify-content: flex-start; }
1259
+ .fx-setting__control > :is(.fx-select, .fx-multi, .fx-input:not([type="number"])) { width: 100%; }
1219
1260
  .fx-setting__stack { justify-items: start; }
1220
1261
  .fx-setting__control .fx-swatches { flex-wrap: wrap; }
1221
1262
  }
@@ -1302,6 +1343,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1302
1343
 
1303
1344
  /* ── Phones ─────────────────────────────────────────────────────────────── */
1304
1345
  @media (max-width: 639px) {
1346
+ /* Touch: every control a thumb uses is 44px (docs/MOBILE_TESTS.md). The
1347
+ icons and text keep their size; the box around them grows. */
1348
+ #menu-toggle, #search { width: 44px; height: 44px; }
1349
+ .fx-tabs button { min-height: 44px; }
1350
+ /* The notice's buttons grow to 44px into its padding, so the notice
1351
+ stays one short line. */
1352
+ .fx-alerts__close { width: 44px; height: 44px; margin: -12px -8px; }
1353
+ .fx-alerts__action { display: inline-flex; align-items: center; min-height: 44px; margin-block: -12px; }
1305
1354
  .fx-main { padding-top: 20px; }
1306
1355
  /* A two-line list needs the rows, not the gaps, so seven fit at 375×667.
1307
1356
  The alert stays one line. The toolbar stays one scrolling row. */
@@ -1355,7 +1404,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1355
1404
  [data-phone-layout="two-line"] .fx-table tbody tr:not(.fx-table__group) {
1356
1405
  display: flex; flex-wrap: wrap; align-items: center; position: relative;
1357
1406
  box-sizing: border-box; height: 56px; min-height: 56px; max-height: 56px;
1358
- margin: 0; padding: 6px 12px 6px 44px; overflow: hidden;
1407
+ margin: 0; padding: 6px 12px 6px 44px; overflow: hidden; column-gap: 8px;
1359
1408
  border-bottom: 1px solid var(--fx-line-soft);
1360
1409
  }
1361
1410
  [data-phone-layout="two-line"] .fx-table tbody tr:not(.fx-table__group)::after {
@@ -1367,29 +1416,47 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1367
1416
  display: block; width: auto; margin: 0; padding: 0; border: 0;
1368
1417
  white-space: nowrap; background: none;
1369
1418
  }
1370
- [data-phone-layout="two-line"] .fx-tick-cell {
1419
+ [data-phone-layout="two-line"] .fx-table td.fx-tick-cell {
1371
1420
  position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center;
1372
1421
  width: 44px; height: 56px; padding: 0;
1373
1422
  }
1374
- [data-phone-layout="two-line"] .fx-tick-cell .fx-tick { width: 44px; height: 44px; margin: 0; }
1423
+ /* The tick keeps a 44px hit area, but shows an 18px box, as on a desk. */
1424
+ [data-phone-layout="two-line"] .fx-tick-cell .fx-tick {
1425
+ appearance: none; -webkit-appearance: none; display: grid; place-items: center;
1426
+ width: 44px; height: 44px; margin: 0; border: 0; background: none;
1427
+ }
1428
+ [data-phone-layout="two-line"] .fx-tick::before,
1429
+ [data-phone-layout="two-line"] .fx-tick::after { content: ""; grid-area: 1 / 1; box-sizing: border-box; }
1430
+ [data-phone-layout="two-line"] .fx-tick::before {
1431
+ width: 18px; height: 18px; border: 1.5px solid var(--t-brd-hi); border-radius: calc(var(--t-radius) / 2); background: var(--t-paper);
1432
+ }
1433
+ [data-phone-layout="two-line"] .fx-tick:is(:checked, :indeterminate)::before { border-color: var(--t-brand); background: var(--t-brand); }
1434
+ [data-phone-layout="two-line"] .fx-tick:checked::after {
1435
+ width: 10px; height: 6px; margin-top: -2px; border: solid var(--t-on-primary); border-width: 0 0 2px 2px; transform: rotate(-45deg);
1436
+ }
1437
+ [data-phone-layout="two-line"] .fx-tick:indeterminate::after { width: 10px; height: 2px; background: var(--t-on-primary); }
1375
1438
  [data-phone-layout="two-line"] [data-phone-slot="title"] {
1376
1439
  order: 1; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis;
1377
1440
  color: var(--t-strong); font-weight: 600;
1378
1441
  }
1442
+ /* The title link keeps a 44px hit area but takes 24px of the line, so
1443
+ line 2 still fits in the 56px row. */
1379
1444
  [data-phone-layout="two-line"] [data-phone-slot="title"] :is(a, button) {
1380
- display: inline-flex; align-items: center; max-width: 100%; min-height: 44px;
1445
+ display: inline-flex; align-items: center; max-width: 100%; min-height: 44px; margin-block: -10px;
1381
1446
  }
1382
1447
  [data-phone-layout="two-line"] [data-phone-slot="end"] {
1383
1448
  order: 2; flex: none; margin-left: 8px; font-variant-numeric: tabular-nums;
1384
1449
  }
1385
1450
  [data-phone-layout="two-line"] [data-phone-slot="meta"] {
1386
1451
  order: 5; flex: 0 1 auto; min-width: 0; max-width: 42%; overflow: hidden; text-overflow: ellipsis;
1387
- color: var(--t-dim); font-size: 12px; line-height: 16px;
1452
+ color: var(--t-dim); font-size: 12px; line-height: 16px; height: 20px;
1388
1453
  }
1454
+ /* Line 2 is 20px; its cells centre on it. */
1455
+ [data-phone-layout="two-line"] .fx-table td[data-phone-slot="meta"] { display: flex; align-items: center; overflow: visible; }
1389
1456
  [data-phone-layout="two-line"] td.fx-c-stage,
1390
1457
  [data-phone-layout="two-line"] td.fx-c-status { order: 4; color: inherit; }
1391
1458
  [data-phone-layout="two-line"] .fx-stage { padding-block: 0; line-height: 16px; }
1392
- [data-phone-layout="two-line"] [data-phone-slot="hide"],
1459
+ [data-phone-layout="two-line"] .fx-table td[data-phone-slot="hide"],
1393
1460
  [data-phone-layout="two-line"] td.fx-c-end { display: none; }
1394
1461
  [data-phone-layout="two-line"] tr.fx-table__group {
1395
1462
  display: flex; align-items: center; box-sizing: border-box; height: 32px; min-height: 32px; max-height: 32px;
@@ -1397,7 +1464,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1397
1464
  border-bottom: 1px solid var(--fx-line-soft);
1398
1465
  }
1399
1466
  [data-phone-layout="two-line"] tr.fx-table__group td { height: 32px; padding: 0; background: none; }
1400
- [data-phone-layout="two-line"] tr.fx-table__group .fx-tick-cell { height: 32px; }
1467
+ [data-phone-layout="two-line"] .fx-table tr.fx-table__group td.fx-tick-cell { height: 32px; }
1401
1468
  [data-phone-layout="two-line"] tr.fx-table__group .fx-tick-cell .fx-tick { width: 44px; height: 44px; }
1402
1469
  /* Tiles: every card is one 56px row, as a list row: the mark, the name,
1403
1470
  the status and ›. The blurb and the button stay on the page it opens. */
@@ -1433,15 +1500,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1433
1500
  control on the right. */
1434
1501
  .fx-set { display: grid; gap: 4px; }
1435
1502
  .fx-set__title { margin: 8px 0 4px; color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
1436
- .fx-set__row { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 0; }
1437
- .fx-set__row + .fx-set__row { border-top: 1px solid var(--fx-line); }
1438
- .fx-set__text { display: grid; gap: 2px; min-width: 0; }
1439
- .fx-set__name { color: var(--t-text); font: 500 14px/1.35 var(--t-body); }
1440
1503
  .fx-set__hint { color: var(--t-dim); font-size: 13px; line-height: 1.35; }
1441
- .fx-set__control { flex: none; display: flex; justify-content: flex-end; }
1442
- .fx-set__control .fx-input { width: 220px; }
1443
- .fx-set__control .fx-input[type="number"] { width: 96px; }
1444
- .fx-set__control .fx-select { min-width: 220px; }
1445
1504
  .fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
1446
1505
  /* Price list (price-list.js): one plain table of Flow's codes. */
1447
1506
  .fx-price { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
@@ -1633,7 +1692,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1633
1692
  menu on line 1, up to three facts on line 2, 56px. */
1634
1693
  .fx-cfm__row {
1635
1694
  display: flex; flex-wrap: wrap; align-items: center; box-sizing: border-box;
1636
- height: 56px; min-height: 56px; max-height: 56px; padding: 6px 8px 6px 16px; overflow: hidden;
1695
+ height: 56px; min-height: 56px; max-height: 56px; padding: 4px 8px 4px 16px; overflow: hidden; column-gap: 8px;
1637
1696
  }
1638
1697
  .fx-cfm__row::after { content: ""; order: 3; flex-basis: 100%; height: 0; }
1639
1698
  .fx-cfm__row > * { display: none; }
@@ -1643,10 +1702,11 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1643
1702
  display: block; order: 5; flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden;
1644
1703
  text-overflow: ellipsis; white-space: nowrap; color: var(--t-dim); font-size: 12px; line-height: 16px;
1645
1704
  }
1705
+ /* Line 2 is 20px: the type picker keeps its size, but takes no more. */
1706
+ .fx-cfm__row > .fx-cfm__type { display: flex; align-items: center; height: 20px; overflow: visible; }
1646
1707
  }
1647
1708
  /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
1648
1709
  rule dialog's condition lines. */
1649
- .fx-rule__controls { align-items: center; gap: 10px; }
1650
1710
  .fx-rule__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
1651
1711
  .fx-dialog:has(.fx-rule__dialog) { width: min(680px, calc(100vw - 32px)); }
1652
1712
  .fx-rule__dialog .fx-btn--sm { justify-self: start; }
@@ -1660,11 +1720,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1660
1720
  @media (max-width: 639px) {
1661
1721
  .fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
1662
1722
  }
1663
- @media (max-width: 639px) {
1664
- .fx-set__row { gap: 12px; }
1665
- .fx-set__control .fx-input { width: 140px; }
1666
- .fx-set__control .fx-input[type="number"] { width: 72px; }
1667
- .fx-set__control .fx-select { min-width: 140px; }
1668
1723
  }
1669
1724
  .fx-multi { position: relative; width: 220px; max-width: 100%; }
1670
1725
  .fx-multi__summary { display: flex; align-items: center; list-style: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1685,6 +1740,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1685
1740
  .fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
1686
1741
  .fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
1687
1742
  .fx-switch:disabled { cursor: default; opacity: 0.6; }
1743
+ /* On a touch screen the switch keeps its look and gets a 44px target: a
1744
+ clear border round it that only the finger sees. */
1745
+ @media (max-width: 1023px) {
1746
+ .fx-switch { box-sizing: content-box; border: 11px solid transparent; background-clip: padding-box; margin: -11px; }
1747
+ /* A section title's help link: a 44px target that does not grow the title. */
1748
+ .fx-set__title .fx-help { min-width: 44px; min-height: 44px; justify-content: center; align-items: center; margin-block: -12px; }
1749
+ /* A setting's ? gets the same 44px target. */
1750
+ .fx-setting__head .fx-help { min-width: 44px; min-height: 44px; justify-content: center; align-items: center; margin-block: -12px; }
1751
+ /* An integration page's buttons, boxes and menus, and every crumb link. */
1752
+ .fx-int-page :is(.fx-btn, .fx-input, .fx-select, .fx-multi__summary, .fx-multi__option, .fx-meta a) { min-height: 44px; }
1753
+ .fx-int-page .fx-meta a { display: inline-flex; align-items: center; margin-block: -10px; }
1754
+ .fx-crumbs a { display: inline-flex; align-items: center; min-height: 44px; margin-block: -12px; }
1755
+ }
1688
1756
 
1689
1757
  /* ── Sentry's report form (monitoring.js) ──────────────────────────────────
1690
1758
  The form lives in a shadow root and reads these variables from its host.
@@ -1734,7 +1802,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1734
1802
  .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
1735
1803
  .fx-pick__body { display: grid; gap: 12px; }
1736
1804
  .fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
1737
- .fx-pick__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--fx-line); cursor: pointer; }
1805
+ .fx-pick__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; min-height: 44px; padding: 10px 14px; border-bottom: 1px solid var(--fx-line); cursor: pointer; }
1738
1806
  .fx-pick__row:last-child { border-bottom: 0; }
1739
1807
  .fx-pick__row:hover, .fx-pick__row:has(input:checked) { background: var(--fx-hover); }
1740
1808
  .fx-pick__row[hidden] { display: none; }
@@ -1768,7 +1836,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1768
1836
  .fx-listhead > .fx-toolbar:not(.fx-toolbar--plain) > :not(.fx-tabs) { margin-bottom: 8px; }
1769
1837
  @media (max-width: 820px) { .fx-listhead[data-sticky] { top: 60px; } }
1770
1838
  /* On a phone the head would hide too many rows: it scrolls away. */
1771
- @media (max-width: 639px) { .fx-listhead[data-sticky] { position: static; } }
1839
+ @media (max-width: 639px) {
1840
+ /* The head does not stick on a phone, so it needs no top padding. It
1841
+ takes back the gap of the edge marker, and never covers the notice. */
1842
+ .fx-listhead[data-sticky] { position: static; margin-top: 0; padding-top: 0; }
1843
+ .fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -8px; }
1844
+ .fx-alerts { margin-bottom: 4px; }
1845
+ }
1772
1846
 
1773
1847
  /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1774
1848
  ticked. "N selected ×", then icon actions in groups, then More. It takes
@@ -1932,7 +2006,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1932
2006
  .fx-xu__fold { padding: 12px; }
1933
2007
  }
1934
2008
 
1935
- /* Contacts (server/ui/contacts.js) and the pending-invoice Create offer.
2009
+ /* Contacts (server/ui/contacts.js) and the pending-invoice Link and Create offer.
1936
2010
  Phone targets stay on this page only, so other screenshots stay valid. */
1937
2011
  .fx-offer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
1938
2012
  .fx-offer > .fx-notice { flex: 1 1 220px; }
@@ -1946,6 +2020,12 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1946
2020
  .fx-contacts .fx-person { color: inherit; text-decoration: none; }
1947
2021
  @media (max-width: 639px) {
1948
2022
  .fx-contacts__grid { grid-template-columns: minmax(0, 1fr); }
2023
+ /* The note on its own line, then Link and Create side by side. */
2024
+ .fx-offer > .fx-notice { flex-basis: 100%; }
2025
+ .fx-offer > .fx-btn { flex: 1 1 0; }
2026
+ /* A short note fits on one row with Link and Create. */
2027
+ .fx-offer > .fx-notice:has(.fx-notice__short) { display: flex; flex: 1 1 auto; align-self: stretch; align-items: center; }
2028
+ .fx-offer:has(.fx-notice__short) > .fx-btn { flex: 0 1 auto; }
1949
2029
  .fx-offer .fx-btn,
1950
2030
  .fx-contacts :is(.fx-btn, .fx-input, .fx-select, .fx-check) { min-height: 44px; }
1951
2031
  .fx-contacts .fx-help { min-width: 44px; min-height: 44px; }
@@ -42,15 +42,13 @@ export function ruleSummary(rule, meta) {
42
42
  }
43
43
 
44
44
  export function rulesSection(ui, title = 'Rules') {
45
- const { h, flow, toast, notice, dialog, me } = ui;
45
+ const { h, flow, toast, notice, dialog, me, settingRow } = ui;
46
46
  const disabled = !['owner', 'admin'].includes(me?.person.role);
47
47
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
48
48
  let meta;
49
49
  let rules = [];
50
50
 
51
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
52
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
53
- h('div', { class: 'fx-set__control fx-rule__controls' }, control));
51
+ const row = (label, hint, control) => settingRow({ label, hint, control });
54
52
 
55
53
  async function send(path, method, payload) {
56
54
  try {
@@ -83,7 +83,7 @@ export function renderInvoicingSettings(main, ui, id) {
83
83
 
84
84
  // Payment terms and the Reference template.
85
85
  function draftsSection(ui) {
86
- const { h, flow, toast, notice, me } = ui;
86
+ const { h, flow, toast, notice, me, settingRow } = ui;
87
87
  const disabled = !['owner', 'admin'].includes(me?.person.role);
88
88
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Drafts'), h('div', { class: 'fx-skeleton' }));
89
89
  let saved;
@@ -102,9 +102,7 @@ function draftsSection(ui) {
102
102
  }
103
103
  }
104
104
 
105
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
106
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
107
- h('div', { class: 'fx-set__control' }, control));
105
+ const row = (label, help, control) => settingRow({ label, help, control });
108
106
 
109
107
  // A text or number box saves when it loses focus with a new value.
110
108
  const box = (key, label, { type = 'text', read = (v) => v, show = (v) => v ?? '', placeholder } = {}) => {
@@ -141,9 +139,7 @@ export function sendModeSection(ui) {
141
139
  const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
142
140
  SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
143
141
  const section = h('section', { class: 'fx-set' }, ui.sectionTitle('Sending', { href: DOCS, label: 'How sending works' }),
144
- h('div', { class: 'fx-set__row' },
145
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
146
- h('div', { class: 'fx-set__control' }, select)));
142
+ ui.settingRow({ label: 'Send to Xero as', help: 'Draft: approve in Xero. Approved: Xero emails it.', control: select }));
147
143
  let saved = null;
148
144
  (async () => {
149
145
  try {
@@ -10,7 +10,7 @@ import { bulkBar } from '/assets/ui/bulk-bar.js';
10
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
11
11
  import { caseNumber } from '/assets/ui/job-board.js';
12
12
  import { jobChangeReview } from '/assets/ui/job-change.js';
13
- import { rowMenu } from '/assets/ui/kit.js';
13
+ import { cardRows, rowMenu } from '/assets/ui/kit.js';
14
14
  import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
15
15
  import { recordBar, watchPhonePrimary } from '/assets/ui/sidecar.js';
16
16
  import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
@@ -18,6 +18,7 @@ import { fieldGroup, fieldIdOf, filterCount, filterJobs, INVOICE_COLUMNS, invoic
18
18
  import { INVOICE_FILTER_FIELDS, INVOICE_FILTER_STATUSES, invoiceFilterKind, invoiceKindOf, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
19
19
  import { listPager, pager, PAGE_SIZES } from '/assets/ui/pager.js';
20
20
  import { savedViews } from '/assets/ui/saved-views.js';
21
+ import { xeroContactActions } from '/assets/ui/xero-contact.js';
21
22
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
22
23
 
23
24
  // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
@@ -914,6 +915,12 @@ function linkedCard(ui, invoice, accountingStatus = null) {
914
915
 
915
916
  // The first sentence of a problem: what is wrong. The red field shows where,
916
917
  // so the banner holds no how-to.
918
+ // An invoice line on a phone (CardRows): Item, Qty and Remove; Description;
919
+ // Unit price, Tax and Amount. An issued line: Description and Amount; Qty,
920
+ // Unit price and Tax.
921
+ const LINE_CARD = { areas: ['item item qty remove', 'desc desc desc desc', 'price gst amount amount'], columns: 'minmax(0, 1fr) minmax(0, 1fr) 72px 44px' };
922
+ const LINE_CARD_READ = { areas: ['desc desc amount', 'qty price gst'], columns: 'repeat(3, minmax(0, 1fr))' };
923
+
917
924
  const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
918
925
 
919
926
  // A draft, or a new invoice before its first save.
@@ -989,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
989
996
 
990
997
  const drawLines = () => {
991
998
  tbody.replaceChildren(...lines.map((line, index) => {
992
- const amount = h('td', { class: 'fx-num fx-strong' });
999
+ const amount = h('td', { class: 'fx-num fx-strong', 'data-area': 'amount', 'data-label': 'Amount', 'data-end': '' });
993
1000
  const showAmount = () => {
994
1001
  amount.textContent = line.unitAmountCents == null || !(line.quantity > 0) ? '—' : formatCents(line.quantity * line.unitAmountCents);
995
1002
  };
@@ -1027,13 +1034,13 @@ function draftView(invoice, ui, context, redraw) {
1027
1034
  onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
1028
1035
  }, '×');
1029
1036
  return h('tr', {},
1030
- h('td', { class: 'fx-lines__item' }, item),
1031
- h('td', {}, description),
1032
- h('td', { class: 'fx-lines__qty' }, quantity),
1033
- h('td', { class: 'fx-lines__price' }, price),
1034
- h('td', { class: 'fx-lines__gst' }, gst),
1037
+ h('td', { class: 'fx-lines__item', 'data-area': 'item', 'data-label': 'Item' }, item),
1038
+ h('td', { 'data-area': 'desc', 'data-label': 'Description' }, description),
1039
+ h('td', { class: 'fx-lines__qty', 'data-area': 'qty', 'data-label': 'Qty' }, quantity),
1040
+ h('td', { class: 'fx-lines__price', 'data-area': 'price', 'data-label': 'Unit price' }, price),
1041
+ h('td', { class: 'fx-lines__gst', 'data-area': 'gst', 'data-label': 'Tax' }, gst),
1035
1042
  amount,
1036
- h('td', {}, remove));
1043
+ h('td', { 'data-area': 'remove' }, remove));
1037
1044
  }));
1038
1045
  };
1039
1046
  drawLines();
@@ -1205,41 +1212,24 @@ function draftView(invoice, ui, context, redraw) {
1205
1212
  // The job changed after the invoice was made: its own banner shows what
1206
1213
  // changed. Other problems stay one line.
1207
1214
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
1208
- const offer = otherProblems.find((p) => p.code === 'xero_contact_missing' && p.offerCreate);
1215
+ const offer = otherProblems.find((p) => ['xero_contact_missing', 'xero_contact_ambiguous'].includes(p.code) && (p.offerLink || p.offerCreate));
1216
+ // A phone shows each problem's short name ("Client not in Xero") on one line.
1217
+ const shorts = otherProblems.every((p) => p.short) ? [...new Set(otherProblems.map((p) => p.short))].join(', ') : null;
1209
1218
  const problemNotice = otherProblems.length
1210
- ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
1219
+ ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
1211
1220
  : null;
1212
1221
  const organisationId = context.organisations[0]?.id;
1213
- const createClient = offer && context.canIssue && invoice?.contact?.id && organisationId
1214
- ? h('button', {
1215
- class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
1216
- onclick: async (event) => {
1217
- const button = event.currentTarget;
1218
- const name = invoice.contact.name;
1219
- const confirmed = await ui.dialog({
1220
- title: `Create ${name} in Xero?`,
1221
- body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
1222
- confirm: 'Create',
1223
- });
1224
- if (!confirmed) return;
1225
- button.disabled = true;
1226
- try {
1227
- await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: invoice.contact.id }) });
1228
- toast(`${name} is in Xero.`, 'success');
1229
- const body = await flow(`/api/invoicing/invoices/${invoice.id}`);
1230
- redraw(body.invoice);
1231
- } catch (error) {
1232
- toast(error.message, error.status === 429 ? 'info' : 'danger');
1233
- if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
1234
- else button.disabled = false;
1235
- }
1236
- },
1237
- }, 'Create')
1238
- : null;
1222
+ // Link or Create the Xero client, then the invoice is checked again.
1223
+ const clientActions = offer && context.canIssue && invoice?.contact?.id && organisationId
1224
+ ? xeroContactActions(ui, {
1225
+ organisationId, contact: invoice.contact, offerLink: offer.offerLink, offerCreate: offer.offerCreate,
1226
+ done: async () => redraw((await flow(`/api/invoicing/invoices/${invoice.id}`)).invoice),
1227
+ })
1228
+ : [];
1239
1229
  const problems = [
1240
1230
  // Accepting changes the draft for the business: owners and admins only.
1241
1231
  jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
1242
- problemNotice ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
1232
+ problemNotice ? (clientActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions) : problemNotice) : null,
1243
1233
  ];
1244
1234
  // A red field names the banner as its reason (for a screen reader).
1245
1235
  const banner = problemNotice;
@@ -1279,12 +1269,12 @@ function draftView(invoice, ui, context, redraw) {
1279
1269
  h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Due date'), dueDate)),
1280
1270
  h('section', { class: 'fx-card fx-lines' },
1281
1271
  datalist,
1282
- h('table', { class: 'fx-lines__table' },
1272
+ cardRows(h('table', { class: 'fx-lines__table' },
1283
1273
  h('thead', {}, h('tr', {},
1284
1274
  h('th', {}, 'Item'), h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'),
1285
1275
  h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'),
1286
1276
  h('th', {}, h('span', { class: 'fx-sr' }, 'Remove')))),
1287
- tbody),
1277
+ tbody), LINE_CARD),
1288
1278
  h('div', { class: 'fx-lines__foot' },
1289
1279
  h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
1290
1280
  totalsHost))),
@@ -1452,14 +1442,14 @@ function issuedView(invoice, ui, context, redraw) {
1452
1442
  h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
1453
1443
  h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
1454
1444
  h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
1455
- h('table', { class: 'fx-lines__table fx-lines__table--read' },
1445
+ cardRows(h('table', { class: 'fx-lines__table fx-lines__table--read' },
1456
1446
  h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
1457
1447
  h('tbody', {}, invoice.lines.map((line) => h('tr', {},
1458
- h('td', {}, line.description),
1459
- h('td', { class: 'fx-num' }, String(line.quantity)),
1460
- h('td', { class: 'fx-num' }, formatCents(line.unitAmountCents)),
1461
- h('td', { class: 'fx-dim' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
1462
- h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
1448
+ h('td', { 'data-area': 'desc' }, line.description),
1449
+ h('td', { class: 'fx-num', 'data-area': 'qty', 'data-label': 'Qty' }, String(line.quantity)),
1450
+ h('td', { class: 'fx-num', 'data-area': 'price', 'data-label': 'Unit price' }, formatCents(line.unitAmountCents)),
1451
+ h('td', { class: 'fx-dim', 'data-area': 'gst', 'data-label': 'Tax' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
1452
+ h('td', { class: 'fx-num fx-strong', 'data-area': 'amount', 'data-end': '' }, formatCents(line.lineAmountCents)))))), LINE_CARD_READ),
1463
1453
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
1464
1454
  h('aside', { class: 'fx-invoice__aside' },
1465
1455
  linkedHost,
@@ -1568,39 +1558,23 @@ function showSent(button) {
1568
1558
  const PICKABLE = new Set(['in_work', 'ready', 'pending']);
1569
1559
 
1570
1560
  async function pickJob(ui) {
1571
- const { h, flow, toast, go } = ui;
1572
- let jobs;
1573
- try {
1574
- jobs = (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((job) => PICKABLE.has(job.view) && !job.invoiceId);
1575
- } catch (error) {
1576
- toast(error.message, 'danger');
1577
- return;
1578
- }
1579
- const search = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find a client or practitioner', 'aria-label': 'Find a job' });
1580
- const rows = jobs.map((job) => {
1581
- const label = h('label', { class: 'fx-pick__row' },
1582
- h('input', { type: 'radio', name: 'job', value: job.id, required: true }),
1583
- h('span', { class: 'fx-pick__main' },
1584
- h('span', { class: 'fx-strong' }, job.contactName ?? 'No client yet'),
1585
- h('span', { class: 'fx-dim' }, [job.practitioner, job.shippedOn ? showDate(job.shippedOn) : null].filter(Boolean).join(' · '))),
1586
- h('span', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
1587
- label.dataset.find = `${job.contactName ?? ''} ${job.practitioner ?? ''}`.toLowerCase();
1588
- return label;
1589
- });
1590
- const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': 'Jobs' },
1591
- rows.length ? rows : h('p', { class: 'fx-dim' }, 'Every job has an invoice already.'));
1592
- search.addEventListener('input', () => {
1593
- const q = search.value.trim().toLowerCase();
1594
- for (const row of rows) row.hidden = Boolean(q) && !row.dataset.find.includes(q);
1595
- });
1596
- const picked = await ui.dialog({
1561
+ const { flow, toast, go } = ui;
1562
+ const job = await ui.pickOne({
1597
1563
  title: 'New invoice from a job',
1598
- body: h('div', { class: 'fx-pick__body' },
1599
- h('p', { class: 'fx-dim' }, 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.'),
1600
- search, list),
1564
+ intro: 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.',
1565
+ label: 'Jobs',
1566
+ find: 'Find a client or practitioner',
1567
+ load: async () => (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((row) => PICKABLE.has(row.view) && !row.invoiceId),
1568
+ item: (row) => ({
1569
+ value: row.id,
1570
+ title: row.contactName ?? 'No client yet',
1571
+ meta: [row.practitioner, row.shippedOn ? showDate(row.shippedOn) : null].filter(Boolean).join(' · '),
1572
+ end: row.totalCents == null ? '—' : formatCents(row.totalCents),
1573
+ }),
1574
+ empty: 'Every job has an invoice already.',
1601
1575
  confirm: 'Make draft',
1602
1576
  });
1603
- const jobId = picked?.get('job');
1577
+ const jobId = job?.id;
1604
1578
  if (!jobId) return;
1605
1579
  try {
1606
1580
  const { invoice } = await flow(`/api/invoicing/jobs/${jobId}/draft`, { method: 'POST', body: '{}' });