@arcforgelabs/flow 2026.10.53-4 → 2026.11.1

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.
@@ -183,6 +183,13 @@
183
183
  "when": 1793972387349,
184
184
  "tag": "0025_powerful_leopardon",
185
185
  "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "7",
190
+ "when": 1794019099844,
191
+ "tag": "0026_bitter_sersi",
192
+ "breakpoints": true
186
193
  }
187
194
  ]
188
195
  }
@@ -911,3 +911,37 @@ export const captures = pgTable('captures', {
911
911
  check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
912
912
  check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
913
913
  ]);
914
+
915
+ // A read grant binds one person and client to one installation and organisation.
916
+ export const agentGrants = pgTable('agent_grants', {
917
+ id: id(),
918
+ personId: uuid('person_id').notNull().references(() => people.id, { onDelete: 'cascade' }),
919
+ clientId: text('client_id').notNull(),
920
+ appName: text('app_name').notNull(),
921
+ resource: text('resource').notNull(),
922
+ organisationId: organisationId().references(() => organisations.id),
923
+ accessLevel: text('access_level').notNull().default('read'),
924
+ createdAt: createdAt(),
925
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
926
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
927
+ failureDay: date('failure_day'),
928
+ failureCount: integer('failure_count').notNull().default(0),
929
+ pausedAt: timestamp('paused_at', { withTimezone: true }),
930
+ }, (t) => [
931
+ unique('agent_grants_person_client_resource').on(t.personId, t.clientId, t.resource),
932
+ check('agent_grants_read_only', sql`access_level = 'read'`),
933
+ ]);
934
+
935
+ // No arguments, token, source evidence, error text or patient data belongs here.
936
+ // Historical UUID snapshots deliberately have no foreign keys: removing a
937
+ // person or grant must not erase or rewrite the retained read history.
938
+ export const agentAudit = pgTable('agent_audit', {
939
+ id: id(),
940
+ createdAt: createdAt(),
941
+ grantId: uuid('grant_id').notNull(),
942
+ personId: uuid('person_id').notNull(),
943
+ tool: text('tool').notNull(),
944
+ status: integer('status').notNull(),
945
+ rowCount: integer('row_count').notNull().default(0),
946
+ durationMs: integer('duration_ms').notNull(),
947
+ }, (t) => [index('agent_audit_created').on(t.createdAt)]);
package/server/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createApp } from './app.js';
3
+ import { createMcpVerifier } from './agents/auth.js';
4
+ import { resolveMcpSettings } from './agents/settings.js';
3
5
  import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkProxyPath } from './auth/clerk.js';
4
6
  import { seedFirstOwner } from './auth/people.js';
5
7
  import { createPersonAuth } from './auth/session.js';
@@ -31,8 +33,14 @@ const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
31
33
 
32
34
  // Settings first: a malformed setting stops boot before anything runs.
33
35
  let settings;
36
+ let mcp;
34
37
  try {
35
38
  settings = resolveSignInSettings();
39
+ const mcpSettings = resolveMcpSettings();
40
+ if (mcpSettings) {
41
+ if (settings.mode !== 'clerk') throw new Error('MCP requires FLOW_AUTH=clerk');
42
+ mcp = { ...mcpSettings, publishableKey: settings.keys.publishableKey, verify: createMcpVerifier({ keys: settings.keys, ...mcpSettings }) };
43
+ }
36
44
  } catch (error) {
37
45
  console.error(`[auth] ${error.message}`);
38
46
  process.exit(1);
@@ -201,7 +209,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
201
209
  const updates = createUpdateChecker();
202
210
 
203
211
  createApp({
204
- database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
212
+ database, mcp, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
205
213
  publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
206
214
  reader: reader ? { on: true, schedule: reader.schedule } : null,
207
215
  }).listen(port, () => {
@@ -78,7 +78,7 @@ export async function stopBackfill(db, { now = Date.now } = {}) {
78
78
  const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
79
79
  const cursor = state?.cursor;
80
80
  if (!cursor?.batchId || cursor.done) throw Object.assign(new FlowError('conflict', 'No history backfill is running'), { status: 409 });
81
- const stopped = { ...cursor, done: true, stopped: true };
81
+ const stopped = { ...cursor, done: true, stopped: true, endedAt: at.toISOString() };
82
82
  await db.update(seazonaLaneState).set({ cursor: stopped, updatedAt: at }).where(eq(seazonaLaneState.lane, LANE));
83
83
  await finishApiBatch(db, cursor.batchId, { at });
84
84
  return describeBackfill(stopped);
@@ -91,6 +91,7 @@ export function describeBackfill(cursor) {
91
91
  from: cursor.from,
92
92
  through: cursor.through,
93
93
  startedAt: cursor.startedAt ?? null,
94
+ endedAt: cursor.endedAt ?? null,
94
95
  nextDay: cursor.done ? null : cursor.nextDay,
95
96
  // The day whose orders it reads now: the last day listed while orders
96
97
  // from it wait, else the next day to list.
@@ -160,6 +161,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
160
161
  if (!cursor.pending.length) {
161
162
  if (cursor.nextDay > cursor.through) {
162
163
  cursor.done = true;
164
+ cursor.endedAt = new Date(at).toISOString();
163
165
  break;
164
166
  }
165
167
  calls += 1;
@@ -53,8 +53,9 @@ export function toLedgerJob(item) {
53
53
  // The Seazona order id is the case number. Never patient data.
54
54
  caseRef: item.sourceId,
55
55
  practitioner: item.doctorName ?? null,
56
- // The Seazona API gives a shipped status but no shipped date.
57
- shippedOn: null,
56
+ // The Seazona API gives a shipped status but no shipped date: the
57
+ // ledger keeps the day it first sees the job shipped.
58
+ shipped: item.shipped === true,
58
59
  // The day of the work, for matching it to its accounting invoice.
59
60
  jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
60
61
  // The draft rules bill only the statuses the lab chose (for example Shipped).
@@ -4,12 +4,13 @@ import expressRateLimit from 'express-rate-limit';
4
4
  // Express's `trust proxy`). In memory: one Flow process serves one lab. Each
5
5
  // call makes its own counter. Built on express-rate-limit, which CodeQL
6
6
  // recognises as rate limiting (js/missing-rate-limiting).
7
- export function rateLimit({ limit, windowMs }) {
7
+ export function rateLimit({ limit, windowMs, ...options }) {
8
8
  return expressRateLimit({
9
9
  limit,
10
10
  windowMs,
11
11
  standardHeaders: 'draft-8', // RateLimit-Policy, RateLimit and Retry-After
12
12
  legacyHeaders: false,
13
13
  handler: (_req, res) => res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' }),
14
+ ...options,
14
15
  });
15
16
  }
package/server/ui/app.js CHANGED
@@ -20,6 +20,7 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
20
20
  import { renderJobs } from '/assets/ui/jobs.js';
21
21
  import { renderReconcile } from '/assets/ui/reconcile.js';
22
22
  import { renderXeroImport } from '/assets/ui/xero-import.js';
23
+ import { syncStatus } from '/assets/ui/sync-status.js';
23
24
  import { xeroUsage } from '/assets/ui/xero-usage.js';
24
25
  import { startMonitoring } from '/assets/ui/monitoring.js';
25
26
  import { isSearchKey, openSearch } from '/assets/ui/search.js';
@@ -998,74 +999,79 @@ function seazonaHistorySection() {
998
999
  const canAct = ['owner', 'admin'].includes(me?.person?.role);
999
1000
  const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
1000
1001
  const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
1001
- const status = h('span', { class: 'fx-history__status' }, 'Checking…');
1002
- const control = h('span', { class: 'fx-row' });
1003
- const bar = h('progress', { class: 'fx-history__bar', max: 1, value: 0, 'aria-label': 'Read so far' });
1004
- const barRow = settingRow({ label: 'Progress', hint: h('span', {}), control: bar, wide: true });
1005
- barRow.hidden = true;
1006
- const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
1007
- const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
1008
- h('h2', { class: 'fx-set__title' }, 'History'),
1009
- settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control }),
1010
- barRow);
1011
- let timer = null;
1012
- let tick = null;
1013
- const press = async (button, path, body) => {
1002
+ const sync = syncStatus();
1003
+ const fromRow = settingRow({ label: 'Read from', help: 'Reads past Seazona orders into Flow. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control: from });
1004
+ const section = h('section', { class: 'fx-set fx-int-section fx-card fx-history' },
1005
+ h('h2', { class: 'fx-set__title' }, 'History'), fromRow, sync.element);
1006
+ let timer;
1007
+ let pending = false;
1008
+ let run = null;
1009
+ let mounted = false;
1010
+ let disposed = false;
1011
+ let loaded = false;
1012
+ const active = () => ['running', 'waiting'].includes(run?.phase);
1013
+ const schedule = () => {
1014
+ clearTimeout(timer);
1015
+ if (!disposed && !document.hidden && active()) timer = setTimeout(load, 5000);
1016
+ };
1017
+ const press = async (button, label) => {
1014
1018
  button.disabled = true;
1019
+ clearTimeout(timer);
1015
1020
  try {
1016
- await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
1017
- toast(body ? 'History read started.' : 'History read stopped.', 'success');
1018
- } catch (error) {
1019
- toast(error.message, 'danger');
1020
- if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
1021
- }
1022
- load();
1021
+ let resume = run?.resumeFrom;
1022
+ if (label === 'Stop' || label === 'Retry') {
1023
+ const stopped = await flow('/api/seazona/history/backfill/stop', { method: 'POST' });
1024
+ resume = stopped.backfill?.resumeFrom;
1025
+ }
1026
+ if (label !== 'Stop') await flow('/api/seazona/history/backfill', {
1027
+ method: 'POST', body: JSON.stringify(label === 'Resume' || label === 'Retry'
1028
+ ? { from: resume ?? run.from, through: run.through } : { from: from.value }),
1029
+ });
1030
+ toast(label === 'Stop' ? 'History read stopped.' : 'History read started.', 'success');
1031
+ } catch (error) { toast(error.message, 'danger'); }
1032
+ loaded = false;
1033
+ await load();
1023
1034
  };
1024
- const start = (label, body, primary = true) => h('button', {
1025
- class: `fx-btn fx-btn--sm${primary ? ' fx-btn--primary' : ''}`, type: 'button', disabled: !canAct,
1026
- onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
1027
- }, label);
1035
+ let actionLabel;
1036
+ const actionButton = h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: event => press(event.currentTarget, actionLabel) });
1028
1037
  const load = async () => {
1029
1038
  clearTimeout(timer);
1030
- clearInterval(tick);
1031
- if (!section.isConnected && timer !== null) return;
1032
- let body;
1039
+ if (disposed || pending || document.hidden) return;
1040
+ pending = true;
1033
1041
  try {
1034
- body = await flow('/api/seazona/history');
1042
+ const body = await flow('/api/seazona/history');
1043
+ if (disposed) return;
1044
+ loaded = true;
1045
+ run = body.backfill;
1046
+ fromRow.hidden = Boolean(run && run.phase !== 'done');
1047
+ const label = !run ? 'Start' : ({ running: 'Stop', waiting: 'Stop', stopped: run.resumeFrom ? 'Resume' : 'Again', failed: 'Retry', done: 'Again' }[run.phase]);
1048
+ actionLabel = label;
1049
+ actionButton.textContent = label;
1050
+ actionButton.disabled = !canAct;
1051
+ sync.draw({
1052
+ phase: run?.phase ?? 'not-started', startedAt: run?.startedAt, endedAt: run?.endedAt,
1053
+ count: body.history.jobs, unit: 'jobs', from: run?.from, through: run?.through,
1054
+ at: run?.readingDay ?? run?.resumeFrom ?? run?.through,
1055
+ cause: run?.cause, nextRunAt: run?.nextRunAt,
1056
+ }, actionButton);
1035
1057
  } catch (error) {
1036
- status.replaceChildren(error.message);
1037
- return;
1038
- }
1039
- const run = body.backfill;
1040
- barRow.hidden = !run;
1041
- if (run) {
1042
- const span = Date.parse(run.through) - Date.parse(run.from) + 86_400_000;
1043
- const at = run.running ? run.readingDay ?? run.through : run.resumeFrom ?? run.through;
1044
- bar.value = run.running || run.resumeFrom ? Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(run.from)) / span)) : 1;
1045
- barRow.querySelector('.fx-setting__hint').replaceChildren(`${day(run.from)} to ${day(run.through)}`);
1046
- }
1047
- if (run?.running) {
1048
- const what = { waiting: 'Waiting', failed: 'Failed' }[run.phase] ?? `Reading ${day(run.readingDay ?? run.through)}`;
1049
- const reading = `${what} · ${run.ordersRead} read${run.waitingToRead ? ` · ${run.waitingToRead} to read` : ''}`;
1050
- const next = h('span', { class: 'fx-dim' });
1051
- const count = () => {
1052
- const left = Math.round((Date.parse(run.nextRunAt) - Date.now()) / 1000);
1053
- next.replaceChildren(left > 0 ? `· ${run.phase === 'running' ? 'next' : 'retry'} in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '· reading now');
1054
- };
1055
- if (run.nextRunAt && run.phase !== 'failed') { count(); tick = setInterval(count, 1000); }
1056
- status.replaceChildren(
1057
- run.phase === 'running' ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : '',
1058
- reading, next, run.cause ? h('span', { class: 'fx-dim' }, `· ${run.cause}`) : '');
1059
- control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
1060
- } else if (run?.resumeFrom) {
1061
- status.replaceChildren(`Stopped at ${day(run.resumeFrom)} · ${run.ordersRead} read`);
1062
- control.replaceChildren(from, start('Start', () => ({ from: from.value }), false), start('Resume', () => ({ from: run.resumeFrom, through: run.through })));
1063
- } else {
1064
- status.replaceChildren(run ? `Done · ${run.ordersRead} read` : 'Not started');
1065
- control.replaceChildren(from, start('Start', () => ({ from: from.value })));
1066
- }
1067
- timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
1058
+ sync.draw({ phase: 'failed', cause: 'Could not check progress. Try again.' }, h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: load }, 'Retry'));
1059
+ } finally { pending = false; schedule(); }
1068
1060
  };
1061
+ const visible = () => { clearTimeout(timer); if (!document.hidden && (!loaded || active())) load(); };
1062
+ document.addEventListener('visibilitychange', visible);
1063
+ const observer = new MutationObserver(() => {
1064
+ if (section.isConnected) mounted = true;
1065
+ else if (mounted) {
1066
+ disposed = true;
1067
+ clearTimeout(timer);
1068
+ sync.dispose();
1069
+ document.removeEventListener('visibilitychange', visible);
1070
+ observer.disconnect();
1071
+ }
1072
+ });
1073
+ observer.observe(document.body, { childList: true, subtree: true });
1074
+ sync.draw({ phase: 'loading' });
1069
1075
  load();
1070
1076
  return section;
1071
1077
  }
@@ -1331,11 +1337,17 @@ drawModeToggles();
1331
1337
  // ── Start ──────────────────────────────────────────────────────────────────
1332
1338
 
1333
1339
  // Clerk's words where they name the app: the installation is Flow, whatever
1334
- // the Clerk instance is called.
1340
+ // the Clerk instance is called. With sign-up on, Clerk shows the combined
1341
+ // sign-in-or-up form, which reads the *Combined keys.
1335
1342
  const LOCALIZATION = {
1336
1343
  signIn: {
1337
1344
  // titleCombined is the sign-in-or-sign-up screen (public sign-up on).
1338
- start: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
1345
+ start: {
1346
+ title: 'Sign in to Flow',
1347
+ subtitle: 'Choose how to sign in.',
1348
+ titleCombined: 'Sign in to Flow',
1349
+ subtitleCombined: 'Choose how to sign in.',
1350
+ },
1339
1351
  password: { subtitle: 'Enter the password for your account.' },
1340
1352
  },
1341
1353
  signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
@@ -639,9 +639,13 @@ a.fx-strong:hover { text-decoration: underline; }
639
639
  .fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
640
640
  .fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
641
641
  /* A row with a trailing control (the sidebar pin). The control does not
642
- close the menu. An unpinned pin shows when the pointer is on the row. */
643
- .fx-pop__row { display: flex; align-items: center; gap: 2px; }
642
+ close the menu. An unpinned pin shows when the pointer is on the row.
643
+ The whole row lights on hover; the pin under the pointer goes back to the
644
+ menu's own colour, so both read. */
645
+ .fx-pop__row { display: flex; align-items: center; gap: 2px; padding-right: 4px; border-radius: var(--t-radius); }
646
+ .fx-pop__row:hover, .fx-pop__row:has(:focus-visible) { background: var(--fx-hover); }
644
647
  .fx-pop__row > .fx-pop__item { flex: 1; min-width: 0; }
648
+ .fx-pop__row > .fx-pop__item:is(:hover, :focus-visible) { background: none; }
645
649
  .fx-pop__row > .fx-pop__item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
646
650
  .fx-pop__pin, .fx-pop__pin--fixed {
647
651
  display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px;
@@ -654,13 +658,14 @@ a.fx-strong:hover { text-decoration: underline; }
654
658
  .fx-pop__row:focus-within .fx-pop__pin[aria-pressed="false"],
655
659
  .fx-pop__pin:focus-visible { opacity: 1; }
656
660
  .fx-pop__pin[aria-pressed="true"], .fx-pop__pin--fixed { color: var(--t-strong); opacity: 1; }
657
- .fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
661
+ .fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--t-paper); color: var(--t-strong); outline: none; }
658
662
  .fx-pop.fx-pop--side { min-width: 240px; }
659
663
  .fx-pop--side .fx-pop__tick { color: inherit; }
660
664
  @media (hover: none) { .fx-pop__pin[aria-pressed="false"] { opacity: 1; } }
661
665
  @media (max-width: 820px) {
662
666
  .fx-pop--side .fx-pop__item { min-height: 44px; }
663
667
  .fx-pop--side .fx-pop__pin, .fx-pop--side .fx-pop__pin--fixed { width: 44px; height: 44px; }
668
+ .fx-pop--side .fx-pop__row { padding-right: 0; }
664
669
  }
665
670
  .fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
666
671
  /* A chip with an × that clears it (as ClickUp's "Clear all filters"). */
@@ -1287,9 +1292,32 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1287
1292
  .fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
1288
1293
  .fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
1289
1294
  .fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
1290
- .fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1291
1295
  .fx-history__day { width: 160px; flex: none; }
1292
- .fx-history__bar { width: 100%; height: 8px; accent-color: var(--t-brand); }
1296
+ /* SyncStatus: Layers, shared by integration history readers. */
1297
+ .fx-history { padding: 8px 20px 16px; }
1298
+ .fx-sync { min-width: 0; padding-block: 8px; }
1299
+ .fx-sync__line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
1300
+ .fx-sync__status { min-width: 0; overflow-wrap: anywhere; color: var(--t-text); font-size: 13px; }
1301
+ .fx-sync__word { position: relative; display: inline-block; overflow: hidden; vertical-align: bottom; }
1302
+ .fx-sync[data-phase="running"] .fx-sync__word::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--t-paper), transparent); opacity: .5; transform: translateX(-100%); animation: fx-sync-sweep 2.6s linear infinite; }
1303
+ .fx-sync[data-phase="failed"] .fx-sync__word { color: var(--t-danger); }
1304
+ .fx-sync[data-phase="done"] .fx-sync__word { color: var(--t-success); }
1305
+ .fx-sync__clock { font: 12px var(--t-data); color: var(--t-dim); }
1306
+ .fx-sync__action { margin-left: auto; }
1307
+ .fx-sync__action .fx-btn { min-width: 44px; min-height: 44px; }
1308
+ .fx-sync__dots { display: inline-flex; gap: 3px; margin-right: 8px; vertical-align: middle; }
1309
+ .fx-sync__dots[hidden], .fx-sync__track[hidden] { display: none; }
1310
+ .fx-sync__dots i { width: 4px; height: 4px; border-radius: var(--t-radius); background: var(--t-accent); animation: fx-sync-breathe 1.8s ease-in-out infinite; }
1311
+ .fx-sync__dots i:nth-child(2) { animation-delay: .2s; }
1312
+ .fx-sync__dots i:nth-child(3) { animation-delay: .4s; }
1313
+ .fx-sync__track { display: grid; gap: 4px; margin-top: 8px; }
1314
+ .fx-sync__rail { width: 100%; height: 4px; accent-color: var(--t-brand); }
1315
+ .fx-sync__range { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; color: var(--t-dim); font-size: 12px; }
1316
+ @media (max-width: 639px) {
1317
+ .fx-sync__line { gap: 4px 8px; }
1318
+ .fx-sync__status { flex: 1 1 60%; }
1319
+ .fx-sync__clock { order: 1; flex-basis: 100%; }
1320
+ }
1293
1321
  .fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
1294
1322
  .fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1295
1323
  .fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
@@ -1539,9 +1567,12 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1539
1567
  }
1540
1568
 
1541
1569
  @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1570
+ @keyframes fx-sync-breathe { 50% { opacity: .35; transform: scale(.75); } }
1571
+ @keyframes fx-sync-sweep { to { transform: translateX(100%); } }
1542
1572
  @keyframes fx-spin { to { transform: rotate(360deg); } }
1543
1573
  @keyframes fx-shimmer { to { background-position: -200% 0; } }
1544
1574
  @media (prefers-reduced-motion: reduce) {
1575
+ .fx-sync__word::after { display: none; }
1545
1576
  *, *::before, *::after { animation: none !important; transition: none !important; }
1546
1577
  }
1547
1578
 
@@ -618,6 +618,23 @@ export const RULES = {
618
618
  ],
619
619
  "card": "Sidecar"
620
620
  },
621
+ "syncStatus": {
622
+ "component": "syncStatus",
623
+ "direction": "Layers",
624
+ "phone": {
625
+ "layout": "wrap"
626
+ },
627
+ "tablet": {
628
+ "layout": "row"
629
+ },
630
+ "desk": {
631
+ "layout": "row"
632
+ },
633
+ "touch": 44,
634
+ "pageOverflow": "none",
635
+ "screens": [],
636
+ "card": "SyncStatus"
637
+ },
621
638
  "fx-tabs": {
622
639
  "component": "fx-tabs",
623
640
  "direction": "scrolling tabs",
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
22
22
  const logoDir = join(here, 'logos');
23
23
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
24
24
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
25
- export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
+ export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
26
26
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
27
27
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
28
28
  const MAX_AGE = '1h';
@@ -0,0 +1,67 @@
1
+ import { h } from './kit.js';
2
+ import { applyPhoneRule } from './mobile.js';
3
+
4
+ const DAY = 86_400_000;
5
+ const words = ['Polishing', 'Glazing', 'Milling', 'Sintering'];
6
+ const date = value => value ? new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '';
7
+ const duration = ms => { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
8
+
9
+ // Neutral, read-only view of server state. No inferred event history. The caller
10
+ // owns actions and polling; dispose stops the display clock when unmounted.
11
+ export function syncStatus() {
12
+ const word = h('span', { class: 'fx-sync__word' });
13
+ const dots = h('span', { class: 'fx-sync__dots', 'aria-hidden': 'true' }, ...[0, 1, 2].map(() => h('i')));
14
+ const result = h('span');
15
+ const status = h('span', { class: 'fx-sync__status', role: 'status', 'aria-live': 'polite' }, dots, word, result);
16
+ const clock = h('span', { class: 'fx-sync__clock', 'aria-label': 'Elapsed', 'aria-live': 'off' });
17
+ const action = h('span', { class: 'fx-sync__action' });
18
+ const rail = h('progress', { class: 'fx-sync__rail', max: 1, value: 0, 'aria-label': 'Days read' });
19
+ const range = h('span');
20
+ const eta = h('span');
21
+ const track = h('div', { class: 'fx-sync__track' }, rail, h('span', { class: 'fx-sync__range' }, range, eta));
22
+ const element = applyPhoneRule(h('div', { class: 'fx-sync' }, h('div', { class: 'fx-sync__line' }, status, clock, action), track), 'syncStatus');
23
+ let current = {};
24
+ let timer;
25
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)');
26
+ const updateClock = () => {
27
+ const { phase, startedAt, endedAt, nextRunAt, count, from, through, at } = current;
28
+ const active = ['running', 'waiting'].includes(phase);
29
+ const elapsed = (active ? Date.now() : Date.parse(endedAt)) - Date.parse(startedAt);
30
+ clock.textContent = Number.isFinite(elapsed) ? duration(elapsed) : '';
31
+ // Rotating words and ticking clocks are decorative, not live announcements.
32
+ if (phase === 'running' && !count) word.textContent = reduced.matches ? 'Reading' : words[Math.floor(Math.max(0, elapsed) / 2600) % words.length] ?? 'Reading';
33
+ eta.textContent = '';
34
+ if (phase === 'waiting' && nextRunAt) {
35
+ const seconds = Math.max(0, Math.ceil((Date.parse(nextRunAt) - Date.now()) / 1000));
36
+ clock.textContent = `${clock.textContent}${clock.textContent ? ' · ' : ''}${seconds}s until retry`;
37
+ }
38
+ const completed = Date.parse(at) - Date.parse(from);
39
+ const total = Date.parse(through) - Date.parse(from) + DAY;
40
+ if (phase === 'running' && count > 0 && completed > 0 && total > completed && elapsed > 0) {
41
+ eta.textContent = `about ${Math.max(1, Math.ceil(elapsed * (total - completed) / completed / 60000))} min left`;
42
+ }
43
+ };
44
+ const draw = (state, button = null) => {
45
+ current = state;
46
+ const { phase = 'not-started', count, unit = 'jobs', from, through, at, cause } = state;
47
+ element.dataset.phase = phase;
48
+ const running = phase === 'running';
49
+ dots.hidden = !running || count > 0;
50
+ word.setAttribute('aria-hidden', String(running && !count));
51
+ status.setAttribute('aria-label', running && !count ? 'Reading' : '');
52
+ word.textContent = running ? (count > 0 ? `Reading${at ? ` ${date(at)}` : ''}` : 'Reading') : ({ waiting: 'Waiting', stopped: 'Stopped', failed: 'Failed', done: 'Done', loading: 'Checking…' }[phase] ?? 'Not started');
53
+ result.textContent = `${count != null && (count > 0 || !running) ? ` · ${count.toLocaleString('en-AU')} ${unit}` : ''}${cause ? ` · ${cause}` : ''}${phase === 'stopped' && at ? ` · ${date(at)}` : ''}`;
54
+ if (action.firstChild !== button) action.replaceChildren(...(button ? [button] : []));
55
+ const total = Date.parse(through) - Date.parse(from) + DAY;
56
+ track.hidden = !(count > 0 && total > 0);
57
+ rail.value = phase === 'done' ? 1 : Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(from)) / total || 0));
58
+ range.textContent = track.hidden ? '' : `${date(from)} – ${date(through)}`;
59
+ clearInterval(timer);
60
+ updateClock();
61
+ if (['running', 'waiting'].includes(phase)) timer = setInterval(() => {
62
+ if (!element.isConnected) { clearInterval(timer); return; }
63
+ if (!document.hidden) updateClock();
64
+ }, 1000);
65
+ };
66
+ return { element, draw, dispose: () => clearInterval(timer) };
67
+ }