@arcforgelabs/flow 2026.10.34 → 2026.10.35

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.
@@ -134,6 +134,13 @@
134
134
  "when": 1792472460808,
135
135
  "tag": "0018_custom_fields",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1792474607940,
142
+ "tag": "0019_people_views",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
146
  }
@@ -177,7 +177,10 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
177
177
  // are stored lower-case. `clerk_user_id` is set on the person's first verified
178
178
  // sign-in; until then the row is an invitation. `appearance` is the person's
179
179
  // own design system, light or dark and accent (server/auth/people.js,
180
- // normaliseAppearance); null until they first choose.
180
+ // normaliseAppearance); null until they first choose. `views` is the
181
+ // person's own view of each page, such as how the Jobs page groups, sorts
182
+ // and filters ({ jobs: … }, server/ui/job-view.js); null until they first
183
+ // change one.
181
184
  export const people = pgTable('people', {
182
185
  id: id(),
183
186
  email: text('email').notNull(),
@@ -188,6 +191,7 @@ export const people = pgTable('people', {
188
191
  invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
189
192
  linkedAt: timestamp('linked_at', { withTimezone: true }),
190
193
  appearance: jsonb('appearance'),
194
+ views: jsonb('views'),
191
195
  createdAt: createdAt(),
192
196
  updatedAt: updatedAt(),
193
197
  }, (t) => [
@@ -23,6 +23,7 @@ export const CAPABILITIES = Object.freeze([
23
23
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
24
24
  'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
25
25
  'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
26
+ 'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
26
27
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
27
28
  'moveWork', // { sourceJobRef, toStatus, personId }: a person moved a job to another stage on the Jobs board; answers what happens (a hand-off when the system's API cannot change it)
28
29
  'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
@@ -8,13 +8,13 @@ import { readBillableJobs } from './reconcile.js';
8
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
9
9
  import { createSeazonaRouter } from './routes.js';
10
10
  import {
11
- cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
11
+ cancelStageMove, expiredStageMoves, handOff, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
12
12
  } from './stage-moves.js';
13
13
  import { orderPatient } from './patients.js';
14
14
  import { createSeazonaScheduler } from './scheduler.js';
15
15
  import { readSweepHealth } from './sweep.js';
16
16
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
17
- import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
17
+ import { describeSeazonaSettings, orderLink, resolveSeazonaSettings } from './settings.js';
18
18
  import { seazonaSchema } from './tables.js';
19
19
 
20
20
  // The Seazona adapter (docs/SEAZONA.md). FLOW_INTEGRATIONS=seazona turns it
@@ -51,7 +51,7 @@ export const SEAZONA_SETUP = {
51
51
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
52
52
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
53
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
- { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. The address of one order in Seazona, with {ref} for the order number. Stage moves on the Jobs board open it.' },
54
+ { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
55
55
  { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
56
56
  ],
57
57
  };
@@ -225,9 +225,15 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
225
225
  const [work, waiting] = await Promise.all([listWork(db, { now: now() }), waitingStageMoves(db)]);
226
226
  // A stage move Seazona has not made yet (stage-moves.js): the card
227
227
  // shows in its new lane, waiting for Seazona.
228
+ // Each card also carries the link that opens its order in Seazona.
229
+ const { settings } = current();
228
230
  for (const item of work.items) {
231
+ item.link = orderLink(settings, { sourceJobRef: item.sourceJobRef });
229
232
  const move = waiting.get(item.sourceJobRef);
230
- if (move) item.pendingMove = { to: move.toStatus, from: move.fromStatus, requestedAt: move.requestedAt.toISOString(), expiresAt: move.expiresAt.toISOString() };
233
+ if (move) {
234
+ const { to, from, requestedAt, expiresAt, openUrl, openExact } = handOff(move, { settings });
235
+ item.pendingMove = { to, from, requestedAt, expiresAt, openUrl, openExact };
236
+ }
231
237
  }
232
238
  return work;
233
239
  },
@@ -237,7 +243,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
237
243
  // records a hand-off and answers what the person does in Seazona.
238
244
  async moveWork({ sourceJobRef, toStatus, personId = null }) {
239
245
  const { db, settings } = current();
240
- return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, orderUrlTemplate: settings.orderUrlTemplate, now: now() });
246
+ return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, settings, now: now() });
241
247
  },
242
248
 
243
249
  async cancelMove({ sourceJobRef }) {
@@ -267,6 +273,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
267
273
  return historyJobIssues(current().db, refs);
268
274
  },
269
275
 
276
+ // The link to one order in Seazona's web app, for "Open in Seazona" on
277
+ // a job's problem (settings.js, orderLink). Never calls Seazona.
278
+ jobLink({ sourceJobRef }) {
279
+ return runtime ? orderLink(runtime.settings, { sourceJobRef }) : null;
280
+ },
281
+
270
282
  // Seazona's products, for the price list (server/core/price-list.js).
271
283
  // Reads Flow's copy only.
272
284
  async listPriceItems() {
@@ -55,9 +55,18 @@ export function normaliseBaseUrl(value) {
55
55
  return `${url.origin}${path}/v1`;
56
56
  }
57
57
 
58
- function orderUrlTemplate(value) {
59
- const raw = text(value);
60
- if (!raw.includes('{ref}')) return null;
58
+ // SEAZONA_ORDER_URL: the address of one order in Seazona's own web app, with
59
+ // {ref} where the order id goes. Seazona's API guide gives no order page
60
+ // address, and each lab has its own, so a person may also paste the address
61
+ // of any one order as the browser shows it: the order id in it (a GUID)
62
+ // becomes {ref}. Answers the template, or null: https only, and an order id
63
+ // or {ref} in it. It never throws; an address Flow cannot read turns it off.
64
+ const ORDER_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
65
+
66
+ export function orderUrlTemplate(value) {
67
+ const given = text(value);
68
+ const raw = given.includes('{ref}') ? given : ORDER_ID.test(given) ? given.replace(ORDER_ID, '{ref}') : '';
69
+ if (!raw) return null;
61
70
  try {
62
71
  return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
63
72
  } catch {
@@ -65,6 +74,18 @@ function orderUrlTemplate(value) {
65
74
  }
66
75
  }
67
76
 
77
+ // The link to one order for "Open in Seazona": the order's own page when the
78
+ // installation set SEAZONA_ORDER_URL, else the home page of the lab's
79
+ // Seazona web app when the API address names it (https://{lab}.seazonaapi.net),
80
+ // else null. `exact` is true only for the order's own page.
81
+ export function orderLink(settings, { sourceJobRef } = {}) {
82
+ const template = settings?.orderUrlTemplate ?? null;
83
+ if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
84
+ const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
85
+ const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
86
+ return lab ? { url: `https://${lab}.seazona.net/`, exact: false } : null;
87
+ }
88
+
68
89
  // Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
69
90
  // null when SEAZONA_MODE is unset: the adapter is then not configured.
70
91
  export function resolveSeazonaSettings(env = process.env) {
@@ -1,6 +1,7 @@
1
1
  import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
2
2
  import { FlowError } from '../../core/errors.js';
3
3
  import { orderStatusLabel } from './map.js';
4
+ import { orderLink } from './settings.js';
4
5
  import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
5
6
 
6
7
  // Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
@@ -16,16 +17,9 @@ export const HAND_OFF_MS = 24 * 60 * 60_000;
16
17
  const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
17
18
  const MAX_STATUS = 80;
18
19
 
19
- // The address that opens one order in Seazona, from SEAZONA_ORDER_URL with
20
- // its {ref} placeholder, or null when the installation has not set one.
21
- export function orderUrl(template, ref) {
22
- if (!template || !template.includes('{ref}')) return null;
23
- return template.replaceAll('{ref}', encodeURIComponent(ref));
24
- }
25
-
26
20
  // Record a person's move of one order to `toStatus`, and answer the hand-off
27
21
  // the board shows. A second move of the same order replaces the first.
28
- export async function requestStageMove(db, { orderId, toStatus, personId = null, orderUrlTemplate = null, now = Date.now() }) {
22
+ export async function requestStageMove(db, { orderId, toStatus, personId = null, settings = null, now = Date.now() }) {
29
23
  const to = typeof toStatus === 'string' ? toStatus.trim() : '';
30
24
  if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
31
25
  const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
@@ -41,7 +35,7 @@ export async function requestStageMove(db, { orderId, toStatus, personId = null,
41
35
  requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
42
36
  };
43
37
  await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
44
- return handOff(row, { orderUrlTemplate });
38
+ return handOff(row, { settings });
45
39
  }
46
40
 
47
41
  // Take a waiting move back: the card returns to Seazona's stage.
@@ -92,8 +86,11 @@ export async function expiredStageMoves(db, { now = Date.now() } = {}) {
92
86
  ));
93
87
  }
94
88
 
95
- // What the board shows for a waiting move.
96
- export function handOff(row, { orderUrlTemplate = null } = {}) {
89
+ // What the board shows for a waiting move. `link` opens the order in
90
+ // Seazona's web app (settings.js, orderLink); `exact` is false when it opens
91
+ // only the lab's Seazona home page.
92
+ export function handOff(row, { settings = null } = {}) {
93
+ const link = orderLink(settings, { sourceJobRef: row.orderId });
97
94
  return {
98
95
  kind: 'hand_off',
99
96
  system: 'seazona',
@@ -104,7 +101,8 @@ export function handOff(row, { orderUrlTemplate = null } = {}) {
104
101
  state: row.state,
105
102
  requestedAt: row.requestedAt.toISOString(),
106
103
  expiresAt: row.expiresAt.toISOString(),
107
- openUrl: orderUrl(orderUrlTemplate, row.orderId),
104
+ openUrl: link?.url ?? null,
105
+ openExact: Boolean(link?.exact),
108
106
  };
109
107
  }
110
108
 
@@ -2,8 +2,9 @@
2
2
  // GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
3
3
  // deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
4
4
  //
5
- // Release names follow docs/RELEASING.md: vYYYY.M.N is stable, and
6
- // vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
5
+ // Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
6
+ // vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N, and vYYYY.M.N-C is a
7
+ // stable correction of YYYY.M.N.
7
8
  //
8
9
  // stable GitHub's latest release (what Flow Release Promote points at, so
9
10
  // a rollback is followed), else npm `latest`
@@ -13,7 +14,7 @@
13
14
  // Nothing here sends anything about the installation: each request is an
14
15
  // anonymous GET of public data.
15
16
 
16
- const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
17
+ const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*)|-([1-9]\d*))?$/;
17
18
  export const CHANNELS = ["stable", "beta"];
18
19
  export const DEFAULT_GITHUB_API = "https://api.github.com";
19
20
  export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
@@ -35,8 +36,9 @@ export function parseReleaseTag(value) {
35
36
  tag,
36
37
  version: tag.slice(1),
37
38
  channel: beta ? "beta" : "stable",
38
- // A stable release sorts after every beta of its own version.
39
- parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY],
39
+ // A stable release sorts after every beta of its own version, and its
40
+ // corrections after it.
41
+ parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY, Number(match[5] ?? 0)],
40
42
  };
41
43
  }
42
44
 
@@ -44,7 +46,7 @@ export function parseReleaseTag(value) {
44
46
  export function compareReleases(a, b) {
45
47
  const [x, y] = [parseReleaseTag(a), parseReleaseTag(b)];
46
48
  if (!x || !y) throw new ReleaseUnavailable(`cannot compare ${a} with ${b}`);
47
- for (let index = 0; index < 4; index += 1) {
49
+ for (let index = 0; index < 5; index += 1) {
48
50
  if (x.parts[index] !== y.parts[index]) return x.parts[index] < y.parts[index] ? -1 : 1;
49
51
  }
50
52
  return 0;
@@ -189,6 +189,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
189
189
  .fx-notice[data-tone="danger"] { border-color: var(--t-danger-brd); background: var(--t-danger-bg); color: var(--t-danger); }
190
190
  .fx-panel > .fx-notice { margin: 12px; }
191
191
 
192
+ /* The job changed after its invoice was made (job-change.js): was / now per
193
+ changed fact. Was is struck through; Now is the work system's value. */
194
+ .fx-change {
195
+ display: grid; gap: 10px; padding: 12px 14px;
196
+ border: 1px solid var(--t-danger-brd); border-radius: var(--t-radius);
197
+ background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
198
+ }
199
+ .fx-change p { margin: 0; }
200
+ .fx-change strong { color: var(--t-danger); }
201
+ .fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
202
+ .fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
203
+ .fx-change__note { font-size: 13px; color: var(--t-dim); }
204
+ .fx-change__scroll { overflow-x: auto; }
205
+ .fx-change__table { width: 100%; border-collapse: collapse; background: var(--t-paper); border: 1px solid var(--t-brd); font-size: 13.5px; }
206
+ .fx-change__table th, .fx-change__table td { padding: 8px 12px; border-bottom: 1px solid var(--t-brd); text-align: left; vertical-align: top; }
207
+ .fx-change__table thead th {
208
+ font-size: var(--t-label-size, 12px); font-weight: var(--t-label-weight, 600); letter-spacing: var(--t-label-spacing, .08em);
209
+ text-transform: var(--t-label-case, uppercase); color: var(--t-dim);
210
+ }
211
+ .fx-change__table tbody th { font-weight: 400; color: var(--t-dim); white-space: nowrap; }
212
+ .fx-change__was { background: color-mix(in srgb, var(--t-danger) 9%, transparent); text-decoration: line-through; text-decoration-thickness: 1px; }
213
+ .fx-change__now { background: color-mix(in srgb, var(--t-success) 11%, transparent); color: var(--t-strong); font-weight: 600; }
214
+ .fx-change__table .fx-num { text-align: left; font-family: var(--t-data); font-variant-numeric: tabular-nums; }
215
+
192
216
  .fx-status {
193
217
  display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px;
194
218
  border-radius: 999px; font-size: 12.5px; font-weight: 600; white-space: nowrap;
@@ -220,7 +244,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
220
244
  letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
221
245
  }
222
246
  .fx-table td { padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); vertical-align: middle; }
223
- .fx-table tbody tr:last-child td { border-bottom: 0; }
247
+ .fx-table tbody:last-child tr:last-child td { border-bottom: 0; }
224
248
  .fx-table tbody tr:hover td { background: var(--fx-hover); }
225
249
  .fx-table__actions { text-align: right; width: 1%; white-space: nowrap; }
226
250
  .fx-table__actions .fx-row { flex-wrap: nowrap; }
@@ -439,7 +463,41 @@ a.fx-strong:hover { text-decoration: underline; }
439
463
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
440
464
  .fx-empty .fx-btn { margin-top: 12px; }
441
465
  .fx-panel.fx-empty { display: grid; justify-items: center; }
442
- .fx-toolbar--end { justify-content: flex-end; }
466
+ /* The Jobs toolbar: Group, Sort and Filter at the left, as small outlined
467
+ buttons that each open a menu; search and Board/List at the right. */
468
+ .fx-toolbar__end { display: flex; flex: 1 1 360px; align-items: center; justify-content: flex-end; gap: 12px; min-width: 0; margin-left: auto; }
469
+ .fx-toolbar__end .fx-search { flex: 0 1 320px; min-width: 0; }
470
+ .fx-viewbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
471
+ .fx-chip {
472
+ display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 4px 11px;
473
+ border: 1px solid var(--fx-line); border-radius: 999px; background: transparent;
474
+ color: var(--t-text); font: 600 13px/1.2 var(--t-body); white-space: nowrap; cursor: pointer;
475
+ }
476
+ .fx-chip svg { flex: none; color: var(--t-dim); }
477
+ .fx-chip:hover, .fx-chip[aria-expanded="true"] { border-color: var(--t-brd-hi, var(--fx-line)); background: var(--fx-hover); color: var(--t-strong); }
478
+ .fx-chip:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
479
+ .fx-chip--on { border-color: var(--t-accent-brd, var(--t-accent)); background: var(--t-accent-bg); color: var(--t-accent); }
480
+ .fx-chip--on svg { color: currentColor; }
481
+ .fx-pop {
482
+ position: fixed; inset: auto; margin: 0; min-width: 220px; max-width: min(320px, calc(100vw - 16px)); max-height: min(420px, calc(100vh - 32px));
483
+ overflow-y: auto; padding: 6px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
484
+ background: var(--t-paper); color: var(--t-text); box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
485
+ }
486
+ .fx-pop__head { margin: 0; padding: 8px 10px 4px; 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); }
487
+ .fx-pop__item {
488
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: var(--t-radius);
489
+ background: none; color: var(--t-text); font: 500 13.5px/1.3 var(--t-body); text-align: left; cursor: pointer;
490
+ }
491
+ .fx-pop__item:hover, .fx-pop__item:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
492
+ .fx-pop__item[aria-checked="true"] { color: var(--t-strong); font-weight: 600; }
493
+ .fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
494
+ .fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
495
+ .fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
496
+ .fx-table__group td { padding: 14px 16px 8px; background: var(--t-s1); }
497
+ .fx-table tbody tr.fx-table__group:hover td { background: var(--t-s1); }
498
+ .fx-table__group-row { display: flex; align-items: center; gap: 12px; }
499
+ .fx-table__group-row .fx-board__sum { margin-left: 0; }
500
+ .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
443
501
  /* The board never scrolls sideways: its columns (one per work system status)
444
502
  are about a phone wide, wrap onto more rows when the page is narrow, and
445
503
  stack on a phone. On a wide, tall screen the board fills the page down to
@@ -499,6 +557,22 @@ a.fx-strong:hover { text-decoration: underline; }
499
557
  .fx-job__problem:hover, .fx-job__problem:focus-visible { background: var(--t-warn-bg); }
500
558
  .fx-job__cell { display: inline-flex; align-items: center; gap: 6px; }
501
559
 
560
+ /* Moves on the board (jobs.js, enableDrag): the lifted card, the copy that
561
+ follows the pointer, the lane that takes it, and the "Waiting for
562
+ Seazona" chip of a stage move the work system has not made yet. */
563
+ .fx-job__wait { justify-self: start; margin-top: 8px; padding: 1px 8px; border: 1px dashed var(--t-accent); border-radius: 999px; color: var(--t-accent); font-size: 11.5px; font-weight: 600; }
564
+ .fx-job--lifted { opacity: .35; }
565
+ .fx-job--ghost { position: fixed; top: 0; left: 0; z-index: 60; pointer-events: none; box-shadow: var(--t-shadow-lg, var(--t-shadow)); cursor: grabbing; }
566
+ .fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
567
+ .fx-board__col--over { outline: 2px solid var(--t-accent); outline-offset: -2px; background: var(--t-accent-bg, var(--t-s1)); }
568
+ .fx-board__lane { font-weight: 600; }
569
+ .fx-board__lane .fx-stage__count { margin-left: 2px; }
570
+ /* The stage move dialog (jobs.js, moveStage). */
571
+ .fx-move__path { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; color: var(--t-dim); }
572
+ .fx-move__stage { padding: 2px 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-strong); font-size: 13px; font-weight: 600; }
573
+ .fx-move__note { margin: 0; color: var(--t-text); }
574
+ .fx-check { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13.5px; cursor: pointer; }
575
+
502
576
  /* ── Job page ───────────────────────────────────────────────────────────────
503
577
  A job opened from the board: a large panel over it, like a task page, with
504
578
  the activity at the side. It stacks on a phone. */
@@ -523,6 +597,11 @@ a.fx-strong:hover { text-decoration: underline; }
523
597
  .fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
524
598
  .fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
525
599
  .fx-task__fixrow:first-of-type { border-top: 0; }
600
+ .fx-task__custom { margin-top: 24px; }
601
+ .fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
602
+ .fx-task__input { width: 100%; min-width: 0; }
603
+ .fx-task__move { display: grid; gap: 10px; max-width: 420px; margin-top: 8px; }
604
+ .fx-task__wait { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 12px; border: 1px dashed var(--t-accent); border-radius: var(--t-radius); font-size: 13.5px; }
526
605
  .fx-task__lines { margin-top: 24px; }
527
606
  .fx-task__lines h3 { margin-bottom: 8px; }
528
607
  .fx-task__lines table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
@@ -21,6 +21,6 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
21
21
  // The line under a draft's title, one fact per sentence.
22
22
  export function draftNote(to, invoice, ready) {
23
23
  if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
24
- if (!ready) return `Mark it ready, then send it to ${to}.`;
24
+ if (!ready) return `Finalise it, then send it to ${to}.`;
25
25
  return sendModeNote(to, invoice?.sendMode, 'this invoice');
26
26
  }