@arcforgelabs/flow 2026.10.43 → 2026.10.45

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.
@@ -7,6 +7,10 @@
7
7
 
8
8
  :root {
9
9
  --fx-line: color-mix(in srgb, var(--t-text) 12%, transparent);
10
+ /* The side space of every page. Pages use the whole width with this slim
11
+ edge, as ClickUp and HubSpot do; the title, toolbar and content start at
12
+ one place on every page. */
13
+ --fx-gutter: clamp(16px, 2vw, 24px);
10
14
  --fx-line-soft: color-mix(in srgb, var(--t-text) 7%, transparent);
11
15
  --fx-hover: color-mix(in srgb, var(--t-text) 5%, transparent);
12
16
  --fx-focus: 0 0 0 3px color-mix(in srgb, var(--t-ring) 40%, transparent);
@@ -90,6 +94,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
90
94
  }
91
95
  .fx-input:hover, .fx-select:hover { border-color: var(--t-text); }
92
96
  .fx-input:focus, .fx-select:focus { outline: none; border-color: var(--t-text); box-shadow: var(--fx-focus); }
97
+ .fx-input[aria-invalid="true"], .fx-select[aria-invalid="true"] {
98
+ border-color: var(--t-danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--t-danger) 28%, transparent);
99
+ }
93
100
  .fx-select {
94
101
  width: auto; min-width: 112px; padding-right: 30px; appearance: none; cursor: pointer;
95
102
  background-image: linear-gradient(45deg, transparent 50%, var(--t-dim) 50%), linear-gradient(135deg, var(--t-dim) 50%, transparent 50%);
@@ -134,17 +141,16 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
134
141
  .fx-toggle__mark { display: none; }
135
142
  .fx-toggle__mark .fx-mark { display: block; }
136
143
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
137
- .fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); 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); }
138
- .fx-nav__group:first-child { padding-top: 4px; }
139
- .fx-nav a, .fx-nav__report {
144
+ .fx-nav a, .fx-nav__report, .fx-nav__more {
140
145
  display: flex; align-items: center; gap: 10px;
141
146
  padding: 8px 10px; border-radius: var(--t-radius);
142
147
  color: var(--t-text); font-weight: 500; font-size: 14px; text-decoration: none; white-space: nowrap;
143
148
  }
144
- .fx-nav a:hover, .fx-nav__report:hover { color: var(--t-strong); background: var(--fx-hover); }
145
- .fx-nav__report { margin-top: auto; border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
149
+ .fx-nav a:hover, .fx-nav__report:hover, .fx-nav__more:hover { color: var(--t-strong); background: var(--fx-hover); }
150
+ .fx-nav__report, .fx-nav__more { border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
151
+ .fx-nav__report { margin-top: auto; }
146
152
  .fx-nav__report[hidden] { display: none; }
147
- .fx-nav a[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
153
+ .fx-nav :is(a, .fx-nav__more)[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
148
154
  .fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
149
155
  .fx-ico { display: inline-flex; flex: none; }
150
156
  .fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@@ -159,15 +165,14 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
159
165
  .fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
160
166
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
161
167
  .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
162
- .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
163
- .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
168
+ .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report, .fx-nav__more) { justify-content: center; padding: 9px 0; }
164
169
  .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
165
170
  .fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
166
171
  .fx-shell[data-folded] #user-button { flex: none; }
167
172
  .fx-shell[data-folded] #user-button .cl-userButtonTrigger { width: auto; padding: 0; border-radius: 50%; background: transparent; box-shadow: none; }
168
173
  .fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier, .fx-shell[data-folded] #user-button .cl-userButtonBox::before { display: none; }
169
174
  }
170
- .fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
175
+ .fx-main { flex: 1; min-width: 0; padding: 32px var(--fx-gutter) 64px; display: grid; gap: 20px; align-content: start; }
171
176
  .fx-main:focus { outline: none; box-shadow: none; }
172
177
 
173
178
  /* Page header: the title only (no line under it), then the actions,
@@ -331,6 +336,25 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
331
336
  background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
332
337
  }
333
338
  .fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
339
+ .fx-tiles { display: grid; gap: 12px; }
340
+ .fx-tiles + .fx-tiles { margin-top: 16px; }
341
+ .fx-tiles__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
342
+ .fx-tiles__head h2 { color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
343
+ .fx-tiles__more { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-size: 13.5px; text-decoration: none; }
344
+ .fx-tiles__more:hover, .fx-tiles__more:focus-visible { color: var(--t-strong); }
345
+ .fx-tiles__more .fx-lucide { width: 15px; height: 15px; }
346
+ .fx-tiles__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 12px; }
347
+ .fx-tile {
348
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 12px 14px 12px 12px;
349
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); color: var(--t-text); text-decoration: none;
350
+ transition: border-color 140ms var(--fx-ease), background-color 140ms var(--fx-ease);
351
+ }
352
+ .fx-tile:hover, .fx-tile:focus-visible { border-color: var(--t-control); background: var(--fx-hover); }
353
+ .fx-tile .fx-logo { width: 48px; height: 48px; border-radius: var(--t-radius); }
354
+ .fx-tile .fx-logo--icon { background: var(--fx-hover); color: var(--t-strong); box-shadow: inset 0 0 0 1px var(--fx-line); }
355
+ .fx-tile h3 { overflow: hidden; color: var(--t-strong); font: 600 14.5px/1.35 var(--t-body); text-overflow: ellipsis; white-space: nowrap; }
356
+ .fx-tile__meta { color: var(--t-dim); font-size: 12.5px; white-space: nowrap; }
357
+ .fx-logo--icon .fx-lucide { width: 22px; height: 22px; }
334
358
  .fx-logo img { width: 28px; height: 28px; object-fit: contain; }
335
359
  .fx-logo--lg { width: 52px; height: 52px; }
336
360
  .fx-logo--lg img { width: 34px; height: 34px; }
@@ -407,6 +431,28 @@ a.fx-strong:hover { text-decoration: underline; }
407
431
  .fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
408
432
  .fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
409
433
 
434
+ /* Saved views (saved-views.js): the row of named view tabs over a list, as
435
+ ClickUp's. The chosen tab is underlined; its ⋯ shows on hover and when
436
+ chosen. Save and Revert sit at the right while the list differs. */
437
+ .fx-views { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 32px; margin-bottom: -8px; }
438
+ .fx-views:empty { display: none; }
439
+ .fx-views__item { display: inline-flex; align-items: center; border-bottom: 2px solid transparent; }
440
+ .fx-views__item[data-on="true"] { border-bottom-color: var(--t-brand); }
441
+ .fx-views__tab { display: inline-flex; align-items: center; gap: 6px; max-width: 220px; padding: 6px 4px 6px 8px; border: 0; background: none; color: var(--t-dim); font: 600 13px var(--t-body); cursor: pointer; }
442
+ .fx-views__tab:hover, .fx-views__item[data-on="true"] .fx-views__tab { color: var(--t-strong); }
443
+ .fx-views__tab:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 1px; }
444
+ .fx-views__tab .fx-lucide { width: 14px; height: 14px; flex: none; }
445
+ .fx-views__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
446
+ .fx-views__dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--t-accent); }
447
+ .fx-views__item .fx-cfm__more { width: 22px; height: 22px; opacity: 0; }
448
+ .fx-views__item:hover .fx-cfm__more, .fx-views__item[data-on="true"] .fx-cfm__more, .fx-views__item .fx-cfm__more[aria-expanded="true"], .fx-views__item .fx-cfm__more:focus-visible { opacity: 1; }
449
+ .fx-views__item .fx-cfm__more .fx-lucide { width: 14px; height: 14px; }
450
+ .fx-views__add { display: inline-flex; align-items: center; gap: 4px; padding: 6px 8px; border: 0; background: none; color: var(--t-dim); font: 500 13px var(--t-body); cursor: pointer; }
451
+ .fx-views__add:hover { color: var(--t-strong); }
452
+ .fx-views__add .fx-lucide { width: 14px; height: 14px; }
453
+ .fx-views__end { display: inline-flex; gap: 6px; margin-left: auto; }
454
+ @media (hover: none) { .fx-views__item .fx-cfm__more { opacity: 1; } }
455
+
410
456
  /* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
411
457
  only while something on the page needs this person. */
412
458
  .fx-alerts { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; padding: 6px 6px 6px 14px; border: 1px solid var(--fx-line); border-left: 3px solid var(--t-warn); border-radius: var(--t-radius); background: var(--t-paper); font-size: 14px; }
@@ -492,8 +538,6 @@ a.fx-strong:hover { text-decoration: underline; }
492
538
  }
493
539
 
494
540
  /* ── Jobs board ─────────────────────────────────────────────────────────── */
495
- /* Every page shares .fx-main's width; only the board's lanes use the whole window. */
496
- .fx-main--board { max-width: none; }
497
541
  .fx-main > * { min-width: 0; }
498
542
  .fx-toolbar--plain { border-bottom: 0; align-items: center; }
499
543
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
@@ -544,14 +588,16 @@ a.fx-strong:hover { text-decoration: underline; }
544
588
  .fx-chipset--on .fx-chip__clear { border-color: var(--t-accent-brd, var(--t-accent)); background: var(--t-accent-bg); color: var(--t-accent); }
545
589
  /* Group by, Sort and Filters: rows of selects with a bin at the end, as
546
590
  ClickUp lays them out, in Flow's own look. */
547
- .fx-pop.fx-pop--rows { width: max-content; min-width: 360px; max-width: min(680px, calc(100vw - 16px)); max-height: min(520px, calc(100vh - 32px)); padding: 12px; }
591
+ .fx-pop.fx-pop--rows { width: max-content; min-width: 360px; max-width: min(760px, calc(100vw - 16px)); max-height: min(520px, calc(100vh - 32px)); padding: 12px; }
548
592
  .fx-rows__title { margin: 0 0 10px; color: var(--t-strong); font: 600 14px/1.3 var(--t-body); }
549
593
  .fx-rows { display: grid; gap: 8px; }
550
- .fx-rows__row { display: grid; grid-template-columns: 78px minmax(130px, 1fr) minmax(120px, auto) minmax(150px, 1.2fr) 32px; gap: 8px; align-items: center; }
594
+ .fx-rows__row { display: grid; grid-template-columns: 64px minmax(140px, 1fr) minmax(120px, auto) minmax(170px, 1.3fr) 32px; gap: 8px; align-items: start; }
551
595
  .fx-rows__row--sort { grid-template-columns: 78px minmax(150px, 1fr) minmax(140px, auto) 32px; }
552
596
  .fx-rows__row--group { grid-template-columns: minmax(170px, 1fr) minmax(130px, auto) 32px; }
553
597
  .fx-rows .fx-select, .fx-rows .fx-input { width: 100%; min-width: 0; min-height: 32px; font-size: 13px; }
554
- .fx-rows__lead { color: var(--t-dim); font: 500 13px/1.2 var(--t-body); }
598
+ /* Rows align at the top, so a value on two lines (a date range) keeps the
599
+ bin beside its first line. */
600
+ .fx-rows__lead { display: flex; align-items: center; min-height: 32px; color: var(--t-dim); font: 500 13px/1.2 var(--t-body); }
555
601
  .fx-rows__value { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
556
602
  .fx-rows__gone { grid-column: span 3; color: var(--t-dim); font-size: 13px; font-style: italic; }
557
603
  .fx-rows__bin {
@@ -565,13 +611,51 @@ a.fx-strong:hover { text-decoration: underline; }
565
611
  .fx-rows__foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; }
566
612
  .fx-rows__add { display: inline-flex; align-items: center; gap: 6px; }
567
613
  .fx-rows__add .fx-lucide { width: 14px; height: 14px; }
614
+ /* The filter panel's groups, as ClickUp's: each group a shaded box with its
615
+ own rows and "Add nested filter"; groups stack with And / Or at the left. */
616
+ .fx-fgroup { display: grid; gap: 8px; padding: 10px; border-radius: var(--t-radius-lg); background: var(--fx-hover); }
617
+ .fx-fgroups { display: grid; gap: 8px; }
618
+ .fx-fgroups__row { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 8px; align-items: start; }
619
+ .fx-fgroups__row > .fx-rows__lead, .fx-fgroups__row > span { padding-top: 16px; }
620
+ .fx-fgroup__add { justify-self: start; padding: 2px 4px; border: 0; background: none; color: var(--t-dim); font: 500 12.5px/1.3 var(--t-body); cursor: pointer; }
621
+ .fx-fgroup__add:hover, .fx-fgroup__add:focus-visible { color: var(--t-strong); text-decoration: underline; outline: none; }
622
+ .fx-choice { display: inline-flex; align-items: center; gap: 6px; min-width: 0; text-align: left; }
623
+ .fx-choice .fx-lucide { flex: none; width: 14px; height: 14px; color: var(--t-dim); }
624
+ .fx-choice__text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
625
+ .fx-rows .fx-rows__join { min-width: 0; width: 100%; padding-left: 8px; font: 600 12px/1 var(--t-data, var(--t-body)); letter-spacing: .04em; }
626
+ .fx-rows__word { font: 600 12px/1 var(--t-data, var(--t-body)); letter-spacing: .04em; padding-left: 9px; }
627
+ .fx-fgroups__row .fx-rows__join { min-width: 0; width: 64px; padding-left: 8px; font: 600 12px/1 var(--t-data, var(--t-body)); letter-spacing: .04em; min-height: 32px; }
628
+ .fx-rows__date { display: flex; flex-wrap: wrap; gap: 6px; overflow: visible; }
629
+ .fx-rows__date > .fx-choice { flex: 1 1 130px; }
630
+ .fx-rows__date .fx-input { width: auto; flex: 1 1 130px; }
631
+ .fx-rows__days { display: inline-flex; flex: 1 1 130px; align-items: center; gap: 6px; }
632
+ .fx-rows__days .fx-input[type="number"] { width: 72px; flex: none; }
633
+ .fx-pop__bar { display: flex; align-items: center; justify-content: space-between; }
634
+ .fx-pop__link { padding: 4px 8px; border: 0; background: none; color: var(--t-accent); font: 500 12.5px/1.3 var(--t-body); cursor: pointer; }
635
+ .fx-pop__link:hover, .fx-pop__link:focus-visible { text-decoration: underline; outline: none; }
568
636
  .fx-pop.fx-pop--choices { min-width: 200px; max-width: 300px; padding: 6px; }
637
+ /* A value list row, as ClickUp's: mark (when there is one), name, tick box
638
+ at the right. No empty space at the left. */
639
+ .fx-pop__item--check { gap: 8px; }
640
+ .fx-pop__item--check .fx-pop__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
641
+ .fx-pop__box { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; margin-left: auto; border: 1px solid var(--t-control); border-radius: var(--t-radius); color: var(--t-on-primary); }
642
+ .fx-pop__item--check[aria-checked="true"] .fx-pop__box { border-color: var(--t-primary); background: var(--t-primary); }
643
+ .fx-pop__box svg { width: 12px; height: 12px; }
644
+ .fx-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--cf, var(--t-dim)); }
645
+ .fx-dot--stage { --stage: var(--t-accent); background: var(--stage); }
646
+ .fx-dot--stage[data-rank="0"] { --stage: var(--t-mute); }
647
+ .fx-dot--stage[data-rank="3"], .fx-dot--stage[data-rank="4"] { --stage: color-mix(in srgb, var(--t-accent) 45%, var(--t-success)); }
648
+ .fx-dot--stage[data-rank="5"], .fx-dot--stage[data-rank="6"] { --stage: var(--t-success); }
649
+ .fx-dot--stage[data-rank="7"] { --stage: var(--t-dim); }
569
650
  .fx-pop--choices .fx-input { min-height: 32px; margin-bottom: 4px; font-size: 13px; }
570
651
  .fx-pop__end { display: inline-grid; margin-left: auto; color: var(--t-accent); }
571
652
  @media (max-width: 640px) {
572
653
  .fx-pop.fx-pop--rows { min-width: 0; width: calc(100vw - 16px); }
573
654
  .fx-rows__row, .fx-rows__row--sort { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; }
574
- .fx-rows__row > .fx-rows__lead, .fx-rows__row > .fx-select:first-child { grid-column: 1 / -1; }
655
+ .fx-rows__row > .fx-rows__lead, .fx-rows__row > .fx-select:first-child, .fx-rows__row > .fx-rows__join { grid-column: 1 / -1; }
656
+ .fx-rows__row > .fx-rows__join { width: 72px; }
657
+ .fx-fgroups__row { grid-template-columns: minmax(0, 1fr); }
658
+ .fx-fgroups__row > .fx-rows__lead, .fx-fgroups__row > span { padding-top: 0; }
575
659
  .fx-rows__row > .fx-rows__value { grid-column: 1 / 3; }
576
660
  .fx-rows__row > .fx-rows__value--none { display: none; }
577
661
  }
@@ -610,23 +694,94 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
610
694
  .fx-th-sort .fx-lucide { width: 13px; height: 13px; }
611
695
  .fx-th-sort__n { font: 700 10px/1 var(--t-body); }
612
696
  @media (hover: none) { .fx-th-sort { opacity: 1; } }
697
+ /* A sized list (columns.js): each column has its width, the table is as
698
+ wide as its columns (never stretched; space left over stays empty) and
699
+ scrolls sideways past the page. A head's right
700
+ edge is a grip that resizes the column; a head drags to move it, and a
701
+ line shows where it lands. */
702
+ .fx-table-scroll { overflow-x: auto; scrollbar-width: thin; }
703
+ .fx-table.fx-table--sized { table-layout: fixed; }
704
+ .fx-table--sized th, .fx-table--sized td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
705
+ .fx-table--sized th { position: relative; }
706
+ /* Every cell is one line: a long value ends in "…" and the row keeps its
707
+ height, as HubSpot's. A wrapper in a cell (a client with its warning
708
+ mark, a stage pill) shrinks its text, never its mark. */
709
+ .fx-table--sized td > * { max-width: 100%; }
710
+ .fx-table--sized .fx-job__cell { display: flex; min-width: 0; }
711
+ .fx-table--sized .fx-job__cell > * { flex: none; }
712
+ .fx-table--sized .fx-job__cell > .fx-strong,
713
+ .fx-table--sized .fx-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
714
+ .fx-table--sized .fx-job__cell > .fx-strong { flex: 0 1 auto; }
715
+ .fx-table--sized .fx-th-cell { max-width: 100%; }
716
+ .fx-table--sized .fx-th { overflow: hidden; text-overflow: ellipsis; }
717
+ .fx-th--drag .fx-th { cursor: grab; touch-action: none; }
718
+ .fx-th--drag[data-dragging] { opacity: .5; }
719
+ .fx-th--drag[data-dragging] .fx-th { cursor: grabbing; }
720
+ .fx-table--sized th[data-drop]::after { content: ""; position: absolute; top: 6px; bottom: 6px; width: 2px; border-radius: 1px; background: var(--t-accent); }
721
+ .fx-table--sized th[data-drop="before"]::after { left: 0; }
722
+ .fx-table--sized th[data-drop="after"]::after { right: 0; }
723
+ .fx-th-grip { position: absolute; top: 0; right: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
724
+ .fx-th-grip::after { content: ""; position: absolute; top: 25%; bottom: 25%; right: 3px; width: 2px; border-radius: 1px; background: transparent; }
725
+ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
726
+ .fx-th-grip:hover::after, .fx-th-grip:focus-visible::after, .fx-table--sized[data-resizing] .fx-th-grip:active::after { background: var(--t-accent); }
727
+ .fx-th-grip:focus-visible { outline: none; }
728
+ .fx-table--sized[data-resizing] { cursor: col-resize; user-select: none; }
729
+ .fx-table--sized .fx-tick-cell, .fx-table--sized .fx-th-add, .fx-table--sized .fx-c-end { overflow: visible; text-overflow: clip; }
730
+ .fx-table--sized .fx-tick-cell { padding-right: 0; }
731
+ /* The end column fills the row after the last column (columns.js), as
732
+ HubSpot's; + sits at its right edge and stays there while the table
733
+ scrolls sideways. */
734
+ .fx-table--sized .fx-th-add { position: sticky; right: 0; z-index: 1; padding-left: 0; padding-right: 12px; text-align: right; }
613
735
  .fx-avatar--sm { width: 24px; height: 24px; font-size: 10px; }
614
736
  .fx-c-date time { font-variant-numeric: tabular-nums; }
615
737
  .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
616
- /* The board is one row of lanes (one per group), each about a phone wide.
617
- Lanes share the width when they fit; more lanes scroll sideways inside
618
- the board, never as a second row. On a wide, tall screen the board fills
738
+ /* The board is one row of lanes (one per group), each a fixed phone width
739
+ (--fx-lane), as HubSpot's: lanes never stretch to fill a wide screen, so
740
+ the space past the last lane stays empty. More lanes scroll sideways
741
+ inside the board, never as a second row. On a wide, tall screen the board fills
619
742
  the page down to its bottom edge, the page itself does not scroll, and
620
743
  each lane scrolls inside itself (jobs.js sets fx-main--board while the
621
744
  board shows). */
622
- .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
745
+ .fx-board { --fx-lane: min(85vw, 272px); display: grid; grid-auto-flow: column; grid-auto-columns: var(--fx-lane); justify-content: start; gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
623
746
  .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
624
747
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
625
748
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
626
749
  .fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
750
+ /* listFrame() in kit.js: the window of a list or board (Jobs, Invoices,
751
+ Reconcile). Its body scrolls both ways; the foot holds the count. A table
752
+ or panel inside drops its own frame. On a wide, tall screen the page is
753
+ the window's height and does not scroll itself: the frame fills it, and
754
+ on the board each lane scrolls inside itself. */
755
+ .fx-frame { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); box-shadow: var(--t-shadow); overflow: hidden; }
756
+ .fx-frame__view { display: flex; flex-direction: column; min-height: 0; }
757
+ .fx-frame__body { min-height: 0; overflow: auto; scrollbar-width: thin; }
758
+ /* edgeFade() in kit.js: a soft fade over each edge with more content past
759
+ it, in the colour under the rows (--fx-fade). */
760
+ .fx-fades { position: relative; --fx-fade: var(--t-paper); }
761
+ .fx-fade { position: absolute; z-index: 3; pointer-events: none; opacity: 0; transition: opacity .15s; }
762
+ .fx-fade[data-edge="top"] { inset: 0 0 auto; height: 24px; background: linear-gradient(to bottom, var(--fx-fade), transparent); }
763
+ .fx-fade[data-edge="bottom"] { inset: auto 0 0; height: 24px; background: linear-gradient(to top, var(--fx-fade), transparent); }
764
+ .fx-fade[data-edge="left"] { inset: 0 auto 0 0; width: 24px; background: linear-gradient(to right, var(--fx-fade), transparent); }
765
+ .fx-fade[data-edge="right"] { inset: 0 0 0 auto; width: 24px; background: linear-gradient(to left, var(--fx-fade), transparent); }
766
+ .fx-fades[data-fade~="top"] > .fx-fade[data-edge="top"], .fx-fades[data-fade~="bottom"] > .fx-fade[data-edge="bottom"],
767
+ .fx-fades[data-fade~="left"] > .fx-fade[data-edge="left"], .fx-fades[data-fade~="right"] > .fx-fade[data-edge="right"] { opacity: 1; }
768
+ .fx-main--board .fx-frame__view { --fx-fade: var(--t-bg); }
769
+ .fx-board__col.fx-fades { --fx-fade: var(--t-s1); }
770
+ .fx-board__col > .fx-fade[data-edge="bottom"] { border-radius: 0 0 var(--t-radius-lg) var(--t-radius-lg); }
771
+ .fx-frame__body .fx-panel { border: 0; border-radius: 0; box-shadow: none; overflow: visible; }
772
+ .fx-frame__body .fx-empty { padding: 24px; }
773
+ .fx-frame .fx-table-scroll { overflow: visible; }
774
+ .fx-frame thead th { position: sticky; top: 0; z-index: 2; }
775
+ .fx-frame__foot { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 6px 12px; border-top: 1px solid var(--fx-line); color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
776
+ .fx-frame__tools { display: flex; align-items: center; gap: 8px; }
777
+ .fx-main--board .fx-frame__body { padding: 12px; background: var(--t-bg); }
627
778
  @media (min-width: 821px) and (min-height: 560px) {
628
- .fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
629
- .fx-main--board > .fx-jobs { min-height: 0; }
779
+ .fx-main--frame { height: 100vh; height: 100dvh; padding-bottom: 16px; display: flex; flex-direction: column; }
780
+ .fx-main--frame > * { flex: none; }
781
+ .fx-main--frame > .fx-frame { flex: 1; min-height: 0; }
782
+ .fx-main--frame .fx-frame__view, .fx-main--frame .fx-frame__body { flex: 1; }
783
+ .fx-main--board .fx-frame__body { display: flex; overflow: hidden; }
784
+ .fx-main--board .fx-jobs { flex: 1; min-width: 0; min-height: 0; }
630
785
  .fx-main--board .fx-board { height: 100%; overflow-y: hidden; align-items: stretch; }
631
786
  .fx-main--board .fx-board__cards { flex: 1; max-height: none; }
632
787
  }
@@ -695,9 +850,9 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
695
850
  .fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
696
851
  .fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
697
852
  .fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; }
698
- /* No sidecar: the main area takes the room. Review is wider for its table. */
853
+ /* No sidecar: the main area takes the room. Fix is wider for the change table. */
699
854
  .fx-task__grid:has(> .fx-sidecar[hidden]) { grid-template-columns: minmax(0, 1fr); }
700
- .fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr) 400px; }
855
+ .fx-task__grid:has(> .fx-sidecar [data-tab="fix"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr) 400px; }
701
856
  .fx-task__side { background: var(--t-s1); }
702
857
  .fx-task__tools { display: flex; align-items: center; gap: 4px; }
703
858
  .fx-task__tools .fx-lucide { width: 16px; height: 16px; }
@@ -715,16 +870,22 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
715
870
  .fx-task__late { color: var(--t-danger) !important; }
716
871
  .fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
717
872
  .fx-task__link:hover { border-bottom-color: currentColor; }
718
- .fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
719
- .fx-task__fix h3, .fx-task__lines h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
720
- .fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
721
- .fx-task__fixrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
722
- .fx-task__fixrow > strong { font-weight: 600; }
723
- .fx-task__fixrow > span { flex: 1 1 160px; color: var(--t-text); }
724
- .fx-task__linkbtn { all: unset; cursor: pointer; color: var(--t-accent); border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
725
- .fx-task__linkbtn:hover { border-bottom-color: currentColor; }
726
- .fx-task__linkbtn:focus-visible { box-shadow: var(--fx-focus); }
727
- .fx-task__fixrow:first-of-type { border-top: 0; }
873
+ .fx-task__lines h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
874
+ /* What to fix: one toggle chip a problem beside the stage; its problem opens
875
+ in the sidecar's Fix tab (jobs.js openJob). One layout with or without. */
876
+ .fx-task__stage { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
877
+ .fx-task__fixchip { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: 999px; background: var(--t-warn-bg); color: var(--t-warn); font-size: 12.5px; font-weight: 600; }
878
+ .fx-task__fixchip .fx-lucide { width: 14px; height: 14px; }
879
+ .fx-task__fixchip:hover { border-color: var(--t-warn); }
880
+ .fx-task__fixchip:focus-visible { box-shadow: var(--fx-focus); }
881
+ /* Pressed: its problem is open in the sidecar's Fix tab. */
882
+ .fx-task__fixchip[aria-pressed="true"] { border-color: var(--t-warn); background: var(--t-warn); color: var(--t-paper); }
883
+ .fx-task__fixitem { display: grid; gap: 8px; justify-items: start; font-size: 13.5px; }
884
+ .fx-task__fixitem > strong { color: var(--t-strong); }
885
+ .fx-task__fixitem > p { margin: 0; }
886
+ .fx-task__fixitem > .fx-task__review { justify-self: stretch; }
887
+ .fx-task__fixgo .fx-lucide { width: 14px; height: 14px; }
888
+ .fx-task__fixgo { display: inline-flex; align-items: center; gap: 6px; }
728
889
  .fx-task__choose { scroll-margin-top: 16px; margin-top: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); color: var(--t-strong); font-size: 13.5px; }
729
890
  .fx-task__choose > svg { flex: none; width: 20px; height: 20px; color: var(--t-warn); }
730
891
  .fx-task__choose > span { flex: 1 1 200px; }
@@ -736,7 +897,6 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
736
897
  invoice opened from Invoices (the pager on top). */
737
898
  .fx-rec { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
738
899
  .fx-rec .fx-lucide { width: 16px; height: 16px; }
739
- .fx-rec__gap { max-width: 1180px; }
740
900
  .fx-rec__gap .fx-toolbar .fx-rows__title { margin: 0; }
741
901
  .fx-rec__gap .fx-ph__actions .fx-btn, .fx-rec__acts .fx-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
742
902
  .fx-rec__results { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
@@ -804,7 +964,7 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
804
964
  .fx-task__grid { grid-template-columns: minmax(0, 1fr); }
805
965
  .fx-task__main { padding: 16px; }
806
966
  .fx-task__fields { grid-template-columns: minmax(0, 1fr); }
807
- .fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr); }
967
+ .fx-task__grid:has(> .fx-sidecar [data-tab="fix"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr); }
808
968
  .fx-task__side { border-top: 1px solid var(--fx-line); border-left: 0; }
809
969
  }
810
970
 
@@ -945,6 +1105,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
945
1105
  /* ── Narrow screens: the sidebar becomes a bar across the top. ──────────── */
946
1106
  @media (max-width: 820px) {
947
1107
  .fx-shell { flex-direction: column; }
1108
+ /* In a column, the page takes the full width, not its content's. */
1109
+ .fx-main { width: 100%; }
948
1110
  .fx-side { flex-direction: row; align-items: center; width: auto; height: 60px; padding: 0 12px; border-right: 0; border-bottom: 1px solid var(--fx-line); }
949
1111
  .fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
950
1112
  .fx-side__head .fx-brand { display: none; }
@@ -987,6 +1149,10 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
987
1149
  .fx-main { padding-top: 20px; }
988
1150
  .fx-title { font-size: 22px; }
989
1151
  .fx-table thead { display: none; }
1152
+ .fx-table.fx-table--sized { width: 100% !important; min-width: 0; table-layout: auto; }
1153
+ .fx-table--sized td { white-space: normal; }
1154
+ .fx-table--sized colgroup { display: none; }
1155
+ .fx-table--sized td.fx-c-end { display: none; }
990
1156
  .fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
991
1157
  .fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
992
1158
  .fx-table td { padding: 4px 0; border: 0; }
@@ -1014,7 +1180,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1014
1180
  .fx-table--jobs tr.fx-row-link > td.fx-c-client { grid-area: 1 / 2; }
1015
1181
  .fx-table--jobs tr.fx-row-link > td.fx-c-total { grid-area: 1 / 3; }
1016
1182
  .fx-table--jobs td.fx-c-end { display: none; }
1017
- .fx-table--jobs tr.fx-row-link > td[data-label]:not(.fx-c-total, .fx-c-stage)::before { content: attr(data-label) " "; color: var(--t-dim); font-size: 12px; }
1183
+ .fx-table--jobs tr.fx-row-link > td[data-label]:not(.fx-c-client, .fx-c-total, .fx-c-stage)::before { content: attr(data-label) " "; color: var(--t-dim); font-size: 12px; }
1018
1184
  .fx-table--jobs tr.fx-table__group { grid-template-columns: 28px minmax(0, 1fr); background: var(--t-s1); }
1019
1185
  .fx-table--jobs tr.fx-table__group > td.fx-tick-cell { grid-row: 1; align-self: center; padding: 0; }
1020
1186
  .fx-table--jobs tr.fx-table__group > td { background: none; }
@@ -1047,7 +1213,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1047
1213
  .fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
1048
1214
  .fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
1049
1215
  @media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
1050
- .fx-price__scroll { overflow-x: auto; border: 1px solid var(--fx-line); background: var(--t-paper); }
1051
1216
  .fx-price__table th { position: sticky; top: 0; z-index: 1; }
1052
1217
  .fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
1053
1218
  .fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
@@ -1217,7 +1382,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1217
1382
  .fx-cfs__where .fx-lucide { width: 16px; height: 16px; color: var(--t-dim); }
1218
1383
  .fx-cfs__foot { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); }
1219
1384
  .fx-cfs__delete { color: var(--t-danger); background: color-mix(in srgb, var(--t-danger) 12%, transparent); }
1220
- .fx-cfs__delete:hover { background: color-mix(in srgb, var(--t-danger) 22%, transparent); }
1385
+ .fx-cfs__delete:hover { background: color-mix(in srgb, var(--t-danger) 28%, transparent); }
1221
1386
  .fx-cfs__delete .fx-lucide { width: 16px; height: 16px; }
1222
1387
  @media (max-width: 720px) {
1223
1388
  .fx-cfm { grid-template-columns: minmax(0, 1fr) 36px 32px; column-gap: 8px; }
@@ -1300,6 +1465,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1300
1465
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
1301
1466
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
1302
1467
 
1468
+ /* The pages of a long list, in the list window's foot bar (pager.js):
1469
+ rows per page, the range, numbered pages. The page shown is filled. */
1470
+ .fx-pages { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
1471
+ .fx-pages__rows { display: inline-flex; align-items: center; gap: 6px; }
1472
+ .fx-pages__size { width: 76px; min-height: 28px; padding-block: 2px; font-size: 13px; }
1473
+ .fx-pages__range { color: var(--t-text); }
1474
+ .fx-pages__steps { display: inline-flex; align-items: center; gap: 4px; }
1475
+ .fx-pages__step, .fx-pages__num { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 6px; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); font: inherit; cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
1476
+ .fx-pages__step:hover:not(:disabled), .fx-pages__num:hover:not([aria-current]) { background: var(--fx-hover); border-color: var(--fx-line); }
1477
+ .fx-pages__step:disabled { opacity: 0.35; cursor: default; }
1478
+ .fx-pages__num[aria-current="page"] { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); cursor: default; }
1479
+ .fx-pages__gap { min-width: 16px; text-align: center; color: var(--t-mute); }
1480
+
1303
1481
  /* New invoice from a job: a searchable list of jobs with no invoice. */
1304
1482
  .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
1305
1483
  .fx-pick__body { display: grid; gap: 12px; }
@@ -1314,12 +1492,21 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1314
1492
  /* The Invoices list head (invoicing.js): tabs, search and the send bar stay
1315
1493
  at the top while rows scroll up under them; a shadow shows once they do.
1316
1494
  Narrow, the app bar (60px) sits above it. */
1317
- .fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
1495
+ /* listHead() in kit.js: the toolbar row of every list. One height, with
1496
+ chips, tabs or a Board/List switch in it, so the rows start at one place. */
1497
+ .fx-listhead > .fx-toolbar { min-height: 40px; }
1498
+ .fx-listhead .fx-toolbar__end { flex: 1 1 auto; }
1499
+ .fx-listhead > .fx-toolbar--plain { align-items: center; }
1500
+ .fx-listhead[data-sticky] { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * var(--fx-gutter)) 0; padding: 12px var(--fx-gutter) 0; background: var(--t-bg); transition: box-shadow .15s; }
1318
1501
  .fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
1319
- .fx-listhead__edge { height: 0; margin-bottom: -20px; }
1320
- @media (max-width: 820px) { .fx-listhead { top: 60px; } }
1502
+ /* The edge tells listHead when the rows reach the toolbar. In the page's
1503
+ grid it takes a row and a gap of its own, so the toolbar moves up by both. */
1504
+ .fx-listhead__edge { height: 0; }
1505
+ .fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -32px; }
1506
+ .fx-listhead > .fx-toolbar > .fx-tabs button { padding-block: 9px; }
1507
+ @media (max-width: 820px) { .fx-listhead[data-sticky] { top: 60px; } }
1321
1508
  /* On a phone the head would hide too many rows: it scrolls away. */
1322
- @media (max-width: 560px) { .fx-listhead { position: static; } }
1509
+ @media (max-width: 560px) { .fx-listhead[data-sticky] { position: static; } }
1323
1510
 
1324
1511
  /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1325
1512
  ticked. "N selected ×", then icon actions in groups, then More. It takes
@@ -1364,7 +1551,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1364
1551
  /* Xero › Import (server/ui/xero-import.js): Review compares one fact per row
1365
1552
  with ✓ or ≠ between the sides; the list and history tick rows for the bulk
1366
1553
  bar. */
1367
- .fx-xi { display: flex; flex-direction: column; gap: 16px; min-width: 0; max-width: 980px; }
1554
+ .fx-xi { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
1368
1555
  .fx-xi .fx-lucide { width: 16px; height: 16px; }
1369
1556
  .fx-xi__views { display: inline-flex; border: 1px solid var(--fx-line); }
1370
1557
  .fx-xi__view { display: inline-grid; place-items: center; width: 40px; height: 36px; border: 0; background: transparent; color: var(--t-dim); cursor: pointer; }
@@ -12,10 +12,10 @@ const TEST_LABELS = {
12
12
  more_than: 'is more than', at_most: 'is at most', is: 'is', is_not: 'is not', is_one_of: 'is one of',
13
13
  is_empty: 'is empty', is_not_empty: 'is not empty', has: 'has', has_not: 'does not have',
14
14
  is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
15
- starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', one_word: 'is one word',
15
+ starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', number_in_words: 'has a number beside words', one_word: 'is one word',
16
16
  };
17
17
  const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
18
- const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'one_word'];
18
+ const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
19
19
  const MANY = ['is_one_of', 'is_not_one_of'];
20
20
  const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
21
21
 
@@ -158,7 +158,7 @@ export function rulesSection(ui, title = 'Rules') {
158
158
  // A pending rule's note: what a person must fix. It shows on the draft.
159
159
  const note = h('input', {
160
160
  class: 'fx-input', maxlength: '200', value: draft?.action.note ?? '', 'aria-label': 'Note',
161
- placeholder: 'For example: Put the practitioner in the job, then check the Reference.',
161
+ placeholder: 'Type a note',
162
162
  });
163
163
  const noteField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Note on the draft'), note);
164
164
  const showNote = () => { noteField.hidden = action.value !== 'pending'; };
@@ -1,5 +1,6 @@
1
- // Settings, Rules (/invoicing-settings): a card for each part, like the
2
- // Integrations page, and one page for each (/invoicing-settings/<key>).
1
+ // Settings, Rules (/invoicing-settings): a section of cards for each part,
2
+ // like Claude's Customize page, and one page for each
3
+ // (/invoicing-settings/<key>).
3
4
  // Drafts holds payment terms and the Reference; Price codes the lines with no
4
5
  // price item (line-mappings.js); Invoices the checks a ready job passes
5
6
  // before Flow drafts its invoice (invoice-rules.js, docs/INVOICE_RULES.md).
@@ -19,43 +20,65 @@ const SEND_MODES = [
19
20
  { value: 'approve_and_email', label: 'Approved and emailed' },
20
21
  ];
21
22
 
22
- const onCount = (list) => ({ label: `${list.filter((x) => x.enabled).length} on`, tone: list.some((x) => x.enabled) ? 'success' : 'mute' });
23
-
24
- // Each card: its page, its mark, and its state once the data is in. No
25
- // blurb: the name and the state say enough.
23
+ // The parts of Rules, each on its own page (/invoicing-settings/<key>).
26
24
  const PARTS = [
27
- { key: 'drafts', title: 'Drafts', mark: 'draft', mount: (ui) => draftsSection(ui) },
28
- { key: 'price-codes', title: 'Price codes', mark: 'tag', mount: (ui) => lineMappingSection(ui),
29
- summary: async (flow) => {
30
- const { labels } = await flow('/api/invoicing/line-labels');
31
- const waiting = labels.filter((l) => l.unmapped > 0).length;
32
- return waiting ? { status: { label: `${waiting} wait`, tone: 'warn' }, action: 'Fix' } : { status: { label: 'All mapped', tone: 'success' } };
33
- } },
34
- { key: 'invoices', title: 'Invoices', mark: 'rules', mount: (ui) => rulesSection(ui, 'Invoices'),
35
- summary: async (flow) => ({ status: onCount((await flow('/api/invoicing/rules')).rules) }) },
25
+ { key: 'invoices', title: 'Invoices', mount: (ui) => rulesSection(ui, 'Invoices') },
26
+ { key: 'drafts', title: 'Drafts', mount: (ui) => draftsSection(ui) },
27
+ { key: 'price-codes', title: 'Price codes', mount: (ui) => lineMappingSection(ui) },
36
28
  ];
37
29
 
30
+ // What a rule does, as its card's mark: Pending, Waits, Never drafted.
31
+ const KIND = {
32
+ pending: { mark: 'flag' },
33
+ wait: { mark: 'clock' },
34
+ skip: { mark: 'ban' },
35
+ };
36
+ const SHOWN = 6;
37
+
38
38
  export function renderInvoicingSettings(main, ui, id) {
39
- const { h, flow, notice, pageHeader, tileCard, crumbs } = ui;
39
+ const { h, flow, notice, pageHeader, tileCard, tileSection, crumbs } = ui;
40
40
  if (id) {
41
41
  const part = PARTS.find((p) => p.key === id);
42
42
  main.append(crumbs(['/invoicing-settings', 'Rules'], part?.title ?? 'Not found'),
43
43
  part ? h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, part.mount(ui)) : notice('No such part of Rules.', 'danger'));
44
44
  return;
45
45
  }
46
- const grid = h('div', { class: 'fx-int-grid' });
47
- main.append(pageHeader('Rules'), grid);
48
- // Each card shows at once and fills in its state when its data comes back.
49
- for (const part of PARTS) {
50
- const href = `/invoicing-settings/${part.key}`;
51
- const slot = tileCard({ href, mark: part.mark, title: part.title });
52
- grid.append(slot);
53
- if (part.summary) {
54
- part.summary(flow)
55
- .then((more) => slot.replaceWith(tileCard({ href, mark: part.mark, title: part.title, ...more })))
56
- .catch(() => {});
46
+ // The landing is Claude's Customize page: a section for each part, one-line
47
+ // cards, and "Show all" to the part's own page, a list.
48
+ const host = h('div', { style: { display: 'grid', gap: '20px' } }, h('div', { class: 'fx-skeleton' }));
49
+ main.append(pageHeader('Rules'), host);
50
+ const page = (key) => `/invoicing-settings/${key}`;
51
+ (async () => {
52
+ let settings;
53
+ let labels;
54
+ let rules;
55
+ try {
56
+ [{ settings }, { labels }, { rules }] = await Promise.all([
57
+ flow('/api/invoicing/settings'), flow('/api/invoicing/line-labels'), flow('/api/invoicing/rules')]);
58
+ } catch (error) {
59
+ host.replaceChildren(notice(error.message, 'danger'));
60
+ return;
57
61
  }
58
- }
62
+ const ruleCard = (rule) => tileCard({
63
+ row: true, href: page('invoices'), mark: (KIND[rule.action.kind] ?? { mark: 'list-checks' }).mark, title: rule.name,
64
+ meta: rule.enabled ? 'On' : 'Off',
65
+ });
66
+ const waiting = labels.filter((l) => l.unmapped > 0).length;
67
+ host.replaceChildren(
68
+ tileSection('Invoices', rules.length ? rules.slice(0, SHOWN).map(ruleCard) : [h('p', { class: 'fx-dim' }, 'No rules yet.')],
69
+ { href: page('invoices'), more: rules.length > SHOWN ? `Show all ${rules.length}` : 'Show all' }),
70
+ tileSection('Drafts', [
71
+ tileCard({ row: true, href: page('drafts'), mark: 'calendar-clock', title: 'Payment terms', meta: `${settings.paymentTermsDays} days` }),
72
+ tileCard({ row: true, href: page('drafts'), mark: 'text-cursor-input', title: 'Reference' }),
73
+ ], { href: page('drafts') }),
74
+ tileSection('Price codes', [
75
+ tileCard({
76
+ row: true, href: page('price-codes'), mark: 'tag', title: 'Line labels',
77
+ status: waiting ? { label: `${waiting} wait`, tone: 'warn' } : { label: 'All mapped', tone: 'success' },
78
+ }),
79
+ ], { href: page('price-codes') }),
80
+ );
81
+ })();
59
82
  }
60
83
 
61
84
  // Payment terms and the Reference template.
@@ -104,7 +127,7 @@ function draftsSection(ui) {
104
127
  disabled ? notice('Only an owner or admin can change these.', 'info') : null,
105
128
  row('Payment terms', 'Days from invoice to due date.', box('paymentTermsDays', 'Payment terms in days', { type: 'number', read: Number })),
106
129
  row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
107
- box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' })),
130
+ box('referenceTemplate', 'Reference rule', { placeholder: 'Type a reference rule' })),
108
131
  ].filter(Boolean));
109
132
  })();
110
133
  return section;