@arcforgelabs/flow 2026.10.25 → 2026.10.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
|
|
4
|
-
import {
|
|
4
|
+
import { sendModeFor } from '../../core/invoicing-rules.js';
|
|
5
|
+
import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
|
|
6
|
+
import { readConnection } from './connections.js';
|
|
5
7
|
import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
|
|
6
8
|
import { xeroError } from './client.js';
|
|
7
9
|
import { withXeroLane } from './governor.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
10
|
+
import { mirrorState } from './lookup.js';
|
|
11
|
+
|
|
12
|
+
// Issued Flow invoices go to Xero. How depends on the customer's send mode
|
|
13
|
+
// (server/core/invoicing-rules.js):
|
|
14
|
+
// - accounting_draft (the default): a Xero draft (ACCREC, DRAFT). The lab
|
|
15
|
+
// approves and sends it in Xero.
|
|
16
|
+
// - approve_and_email: issuing in Flow was the approval. Flow sends it
|
|
17
|
+
// approved (AUTHORISED), then asks Xero to email it to the contact with the
|
|
18
|
+
// organisation's own template.
|
|
19
|
+
// Either way the mirror (mirror.js) reads Xero's status back to Flow.
|
|
11
20
|
//
|
|
12
21
|
// The rules, as in the earlier codebase:
|
|
13
22
|
// - What Flow sends is the issued version's document, exactly. A sent
|
|
@@ -19,7 +28,12 @@ import { withXeroLane } from './governor.js';
|
|
|
19
28
|
// - Before it creates, Flow asks Xero for invoices with the same reference.
|
|
20
29
|
// One for the same contact and total is the same work: Flow records it and
|
|
21
30
|
// creates nothing. One for the same contact at another total within 90
|
|
22
|
-
// days is held for a person, unless they chose to send it anyway.
|
|
31
|
+
// days is held for a person, unless they chose to send it anyway. Flow skips
|
|
32
|
+
// this call for an invoice it drafted from a job when its complete, fresh
|
|
33
|
+
// copy of Xero showed no invoice for that job (docs/JOB_INVOICE_LINKS.md).
|
|
34
|
+
// - Xero's email action is not idempotent. Flow records `sending` before it
|
|
35
|
+
// calls, and calls again only after Xero refused (`failed`), never after an
|
|
36
|
+
// unknown outcome: a person checks Xero then.
|
|
23
37
|
// - The request, with its Idempotency-Key, is stored before it leaves. An
|
|
24
38
|
// uncertain outcome (timeout, 5xx) is resolved later: within Xero's
|
|
25
39
|
// six-minute idempotency window Flow repeats the same request with the same
|
|
@@ -103,7 +117,7 @@ function trackingFor(settings, contact) {
|
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
// The Xero draft for a document, and what stops it.
|
|
106
|
-
export function buildDraft(document, { settings, contact, items }) {
|
|
120
|
+
export function buildDraft(document, { settings, contact, items, sendMode = 'accounting_draft' }) {
|
|
107
121
|
const problems = [];
|
|
108
122
|
const tracking = trackingFor(settings, contact);
|
|
109
123
|
const lines = document.lines.map((line) => {
|
|
@@ -124,7 +138,7 @@ export function buildDraft(document, { settings, contact, items }) {
|
|
|
124
138
|
const issueDate = document.issueDate;
|
|
125
139
|
const draft = {
|
|
126
140
|
Type: 'ACCREC',
|
|
127
|
-
Status: 'DRAFT',
|
|
141
|
+
Status: sendMode === 'approve_and_email' ? 'AUTHORISED' : 'DRAFT',
|
|
128
142
|
Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
|
|
129
143
|
...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
|
|
130
144
|
Reference: xeroReference(document),
|
|
@@ -168,6 +182,8 @@ function mapPush(row) {
|
|
|
168
182
|
verified: row.verified,
|
|
169
183
|
error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
|
|
170
184
|
attempts: row.attempts,
|
|
185
|
+
sendMode: row.send_mode ?? 'accounting_draft',
|
|
186
|
+
email: row.email_state ? { state: row.email_state, error: row.email_error ?? null, at: row.emailed_at ?? null } : null,
|
|
171
187
|
pushedBy: row.pushed_by,
|
|
172
188
|
firstDispatchedAt: row.first_dispatched_at,
|
|
173
189
|
lastDispatchedAt: row.last_dispatched_at,
|
|
@@ -240,8 +256,12 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
240
256
|
if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`));
|
|
241
257
|
if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`));
|
|
242
258
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
243
|
-
const
|
|
259
|
+
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
260
|
+
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
244
261
|
problems.push(...lineProblems);
|
|
262
|
+
if (sendMode === 'approve_and_email' && match.state === 'matched' && !match.contact.email) {
|
|
263
|
+
problems.push(problem('xero_contact_no_email', `The Xero contact "${match.contact.name}" has no email address, so Xero cannot email the invoice. Add one in Xero, then sync.`, { blocking: false }));
|
|
264
|
+
}
|
|
245
265
|
|
|
246
266
|
const pushes = await listPushes(db, organisationId, invoiceId);
|
|
247
267
|
const current = version ? pushes.find((p) => p.version === version) : null;
|
|
@@ -259,6 +279,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
259
279
|
invoiceId,
|
|
260
280
|
number: document.number,
|
|
261
281
|
version,
|
|
282
|
+
sendMode,
|
|
262
283
|
state,
|
|
263
284
|
problems,
|
|
264
285
|
contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
|
|
@@ -298,10 +319,46 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
|
|
|
298
319
|
results.push(failed(invoiceId, error));
|
|
299
320
|
}
|
|
300
321
|
}
|
|
322
|
+
await keepSendState(db, organisationId, results);
|
|
301
323
|
return summarise(results);
|
|
302
324
|
}));
|
|
303
325
|
}
|
|
304
326
|
|
|
327
|
+
// Flow's Problem view (server/core/sending.js) shows an issued invoice that
|
|
328
|
+
// failed to send. Once Xero holds it, by any path, that problem is gone.
|
|
329
|
+
async function keepSendState(db, organisationId, results) {
|
|
330
|
+
const held = results.filter((r) => ['created', 'reconciled', 'existing', 'already_in_xero'].includes(r.outcome)).map((r) => r.invoiceId);
|
|
331
|
+
if (!held.length) return;
|
|
332
|
+
await db.update(flowInvoices).set({ sendState: 'sent', sendError: null, sendUpdatedAt: sql`now()` })
|
|
333
|
+
.where(and(eq(flowInvoices.organisationId, organisationId), inArray(flowInvoices.id, held), sql`${flowInvoices.sendState} is not null`));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Send all ready (server/core/sending.js): issued invoices in batches.
|
|
337
|
+
export async function pushInvoices(db, runtime, { organisationId, invoiceIds, pushedBy = null }) {
|
|
338
|
+
const invoices = [];
|
|
339
|
+
for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
|
|
340
|
+
invoices.push(...(await pushDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH), { pushedBy })).invoices);
|
|
341
|
+
}
|
|
342
|
+
return { invoices };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// What would stop each Flow draft in Xero, from Flow's copy of Xero (no
|
|
346
|
+
// calls). Null while Xero is not connected: then Flow's own checks decide.
|
|
347
|
+
export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
|
|
348
|
+
const connection = await readConnection(db, organisationId);
|
|
349
|
+
if (!connection || connection.mode !== runtime.settings.mode) return null;
|
|
350
|
+
const found = new Map();
|
|
351
|
+
for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
|
|
352
|
+
const { invoices } = await previewDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH));
|
|
353
|
+
for (const preview of invoices) {
|
|
354
|
+
found.set(preview.invoiceId, preview.problems
|
|
355
|
+
.filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
|
|
356
|
+
.map(({ code, message }) => ({ code, message })));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return found;
|
|
360
|
+
}
|
|
361
|
+
|
|
305
362
|
function failed(invoiceId, error, outcome = 'failed') {
|
|
306
363
|
return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
|
|
307
364
|
}
|
|
@@ -316,6 +373,8 @@ function summarise(results) {
|
|
|
316
373
|
mismatched: count('mismatch'),
|
|
317
374
|
ambiguous: count('ambiguous'),
|
|
318
375
|
failed: count('failed') + count('not_tried'),
|
|
376
|
+
emailed: results.filter((r) => r.email?.state === 'sent').length,
|
|
377
|
+
emailProblems: results.filter((r) => r.email && ['failed', 'unknown'].includes(r.email.state)).length,
|
|
319
378
|
},
|
|
320
379
|
invoices: results,
|
|
321
380
|
};
|
|
@@ -347,7 +406,8 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
347
406
|
const pushes = await listPushes(db, organisationId, invoiceId);
|
|
348
407
|
const current = pushes.find((p) => p.version === version);
|
|
349
408
|
if (current && SETTLED.has(current.state)) {
|
|
350
|
-
|
|
409
|
+
const email = await emailIfDue(db, client, current);
|
|
410
|
+
return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero, ...(email ? { email } : {}) };
|
|
351
411
|
}
|
|
352
412
|
if (current?.state === 'mismatch') {
|
|
353
413
|
return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
|
|
@@ -377,14 +437,18 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
377
437
|
{ status: 409 });
|
|
378
438
|
}
|
|
379
439
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
380
|
-
const
|
|
440
|
+
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
441
|
+
const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
381
442
|
if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
|
|
382
443
|
|
|
383
444
|
// Live duplicate check: invoices in Xero with this reference, made by Flow
|
|
384
|
-
// or by hand.
|
|
445
|
+
// or by hand. Not needed when Flow's own copy of Xero already answered it
|
|
446
|
+
// for the job this invoice bills.
|
|
385
447
|
const earlierIds = new Set(pushes.map((p) => p.xero?.invoiceId).filter(Boolean));
|
|
386
|
-
const sameReference = (await
|
|
387
|
-
|
|
448
|
+
const sameReference = (await copyShowsNoInvoice(db, runtime, organisationId, invoiceId))
|
|
449
|
+
? []
|
|
450
|
+
: (await client.findInvoicesByReference(draft.Reference))
|
|
451
|
+
.filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
|
|
388
452
|
const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
|
|
389
453
|
if (!allowExisting) {
|
|
390
454
|
const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
|
|
@@ -406,9 +470,9 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
406
470
|
const preExisting = sameReference.map((inv) => inv.InvoiceID);
|
|
407
471
|
const { rows } = await db.execute(sql`
|
|
408
472
|
insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pre_existing_ids, pushed_by,
|
|
409
|
-
first_dispatched_at, last_dispatched_at)
|
|
473
|
+
first_dispatched_at, last_dispatched_at, send_mode, email_state)
|
|
410
474
|
values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
|
|
411
|
-
${new Date(runtime.now())}, ${new Date(runtime.now())})
|
|
475
|
+
${new Date(runtime.now())}, ${new Date(runtime.now())}, ${sendMode}, ${sendMode === 'approve_and_email' ? 'pending' : null})
|
|
412
476
|
on conflict (invoice_id, version) do nothing returning *`);
|
|
413
477
|
if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
|
|
414
478
|
return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
|
|
@@ -481,7 +545,50 @@ async function verify(db, client, push, draft, document, created, outcome) {
|
|
|
481
545
|
return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
|
|
482
546
|
}
|
|
483
547
|
const settled = await settle(db, push.id, { state: 'created', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status, verified: true });
|
|
484
|
-
|
|
548
|
+
const email = await emailIfDue(db, client, settled);
|
|
549
|
+
return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero, ...(email ? { email } : {}) };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// Email
|
|
554
|
+
|
|
555
|
+
// Xero errors that prove it sent no email: it refused the request.
|
|
556
|
+
const emailRefused = (error) => error.code === 'xero_validation_failed'
|
|
557
|
+
|| (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
|
|
558
|
+
|
|
559
|
+
// Asks Xero to email an approved invoice that Flow sent and checked, once.
|
|
560
|
+
// Answers the email's state, or null when the push sends no email.
|
|
561
|
+
async function emailIfDue(db, client, push) {
|
|
562
|
+
if (push.sendMode !== 'approve_and_email' || !push.email) return null;
|
|
563
|
+
if (!push.verified || !push.xero?.invoiceId || !['pending', 'failed'].includes(push.email.state)) return push.email;
|
|
564
|
+
// Claim the email, so two pushes never both send it.
|
|
565
|
+
const { rows } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sending', updated_at = now()
|
|
566
|
+
where id = ${push.id} and email_state in ('pending', 'failed') returning id`);
|
|
567
|
+
if (!rows.length) return { state: 'sending', error: null, at: null };
|
|
568
|
+
try {
|
|
569
|
+
await client.emailInvoice(push.xero.invoiceId);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
const state = emailRefused(error) ? 'failed' : 'unknown';
|
|
572
|
+
const message = state === 'failed'
|
|
573
|
+
? `Xero did not email the invoice: ${error.message}`
|
|
574
|
+
: 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
|
|
575
|
+
await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
|
|
576
|
+
if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
|
|
577
|
+
return { state, error: message, at: null };
|
|
578
|
+
}
|
|
579
|
+
const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
|
|
580
|
+
where id = ${push.id} returning emailed_at`);
|
|
581
|
+
return { state: 'sent', error: null, at: done.emailed_at };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Flow drafted this invoice from a job, and its complete, fresh copy of Xero
|
|
585
|
+
// showed no invoice for that job: Xero needs no live duplicate check.
|
|
586
|
+
async function copyShowsNoInvoice(db, runtime, organisationId, invoiceId) {
|
|
587
|
+
const { rows: [job] } = await db.execute(sql`select accounting_state from billing_obligations
|
|
588
|
+
where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
|
|
589
|
+
if (job?.accounting_state !== 'not_found') return false;
|
|
590
|
+
const mirror = await mirrorState(db, organisationId, { now: new Date(runtime.now()) });
|
|
591
|
+
return mirror.complete && mirror.fresh;
|
|
485
592
|
}
|
|
486
593
|
|
|
487
594
|
// An earlier attempt whose outcome is unknown.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
3
|
import { enqueueJob, runNextJob } from '../../core/queue.js';
|
|
4
|
+
import { lastSync, syncCatalogue } from './catalogue.js';
|
|
4
5
|
import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
|
|
5
6
|
|
|
6
7
|
// The history import. When an owner connects Xero, Flow copies the
|
|
@@ -17,6 +18,9 @@ const LEASE_MS = 30 * 60 * 1000;
|
|
|
17
18
|
// Xero's day limit resets on a rolling day; an hour is a cheap re-check.
|
|
18
19
|
const BUDGET_WAIT_MS = 60 * 60 * 1000;
|
|
19
20
|
const IDLE_MS = 30 * 1000;
|
|
21
|
+
// Contacts and items are read again once a day, so the matcher can link each
|
|
22
|
+
// customer (server/core/pipeline.js) without a person pressing Sync.
|
|
23
|
+
const CATALOGUE_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
20
24
|
|
|
21
25
|
export async function queueHistoryImport(db, organisationId, { reason = 'connected', runAfter = null } = {}) {
|
|
22
26
|
return enqueueJob(db, organisationId, { kind: IMPORT_KIND, dedupeKey: IMPORT_KEY, payload: { reason }, runAfter });
|
|
@@ -63,6 +67,23 @@ export async function runImportOnce(db, runtime, { workerId = `xero-import-${ran
|
|
|
63
67
|
return ran;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
// Reads the contacts and items of each connected organisation whose last good
|
|
71
|
+
// read is older than a day. A failure is logged and tried again next hour.
|
|
72
|
+
export async function refreshStaleCatalogues(db, runtime, { now = Date.now, log = console } = {}) {
|
|
73
|
+
const { rows } = await db.execute(sql`select organisation_id from xero_connections where mode = ${runtime.settings.mode}`);
|
|
74
|
+
for (const { organisation_id: organisationId } of rows) {
|
|
75
|
+
const last = await lastSync(db, organisationId);
|
|
76
|
+
const at = last?.finishedAt ?? last?.startedAt;
|
|
77
|
+
if (last && at && Number(now()) - new Date(at) < (last.status === 'succeeded' ? CATALOGUE_EVERY_MS : BUDGET_WAIT_MS)) continue;
|
|
78
|
+
try {
|
|
79
|
+
const run = await syncCatalogue(db, runtime, organisationId);
|
|
80
|
+
log.info?.(`[xero] contacts and items read for organisation ${organisationId}: ${run.contactCount} contacts, ${run.itemCount} items`);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
log.error?.(`[xero] reading contacts and items for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
// The background worker: runs due slices one at a time, then waits. Started
|
|
67
88
|
// by the adapter outside replay mode; stop() waits for a slice in flight.
|
|
68
89
|
export function createImportWorker({ db, runtime, log = console, timers = { setTimeout, clearTimeout }, firstDelayMs = 10_000 }) {
|
|
@@ -83,6 +104,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
|
|
|
83
104
|
let busy = false;
|
|
84
105
|
running = (async () => {
|
|
85
106
|
try {
|
|
107
|
+
await refreshStaleCatalogues(db, runtime, { log });
|
|
86
108
|
const ran = await runImportOnce(db, runtime, { workerId });
|
|
87
109
|
if (ran) {
|
|
88
110
|
busy = true;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
|
-
import { pushInvoice } from './drafts.js';
|
|
3
|
+
import { checkDrafts, pushInvoice, pushInvoices } from './drafts.js';
|
|
4
|
+
import { readConnection } from './connections.js';
|
|
4
5
|
import { createImportWorker } from './import.js';
|
|
5
|
-
import { readPayments } from './ledger.js';
|
|
6
|
+
import { readPayments, xeroLiveCheck } from './ledger.js';
|
|
7
|
+
import { findInvoices, listCustomers } from './lookup.js';
|
|
6
8
|
import { createXeroRouter } from './routes.js';
|
|
7
9
|
import { createXeroRuntime, requireXeroSettings } from './runtime.js';
|
|
8
10
|
import { writesAllowed } from './settings.js';
|
|
@@ -30,7 +32,7 @@ export const XERO_SETUP = {
|
|
|
30
32
|
},
|
|
31
33
|
fields: [
|
|
32
34
|
{ name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
|
|
33
|
-
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: '
|
|
35
|
+
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
|
|
34
36
|
{ name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
|
|
35
37
|
{ name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
36
38
|
{ name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
|
|
@@ -85,9 +87,25 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
85
87
|
// One issued invoice to a Xero draft (drafts.js). The Flow invoice id
|
|
86
88
|
// and version are the idempotency key.
|
|
87
89
|
pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
|
|
90
|
+
// Several issued invoices, in batches of MAX_BATCH (drafts.js).
|
|
91
|
+
pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
|
|
92
|
+
// What would stop each Flow draft in Xero, from Flow's copy (no calls).
|
|
93
|
+
checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
|
|
94
|
+
sendStatus: async ({ organisationId }) => {
|
|
95
|
+
const { settings } = current();
|
|
96
|
+
const connection = await readConnection(database, organisationId);
|
|
97
|
+
return { connected: Boolean(connection && connection.mode === settings.mode), writesAllowed: writesAllowed(settings) };
|
|
98
|
+
},
|
|
88
99
|
// Sales invoice payments changed since `since` (ledger.js). The answer's
|
|
89
100
|
// cursor is the next `since`.
|
|
90
101
|
pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
|
|
102
|
+
// Xero's invoice for one billable job, looked up live (ledger.js): the
|
|
103
|
+
// `liveCheck` for draftInvoiceForObligation.
|
|
104
|
+
checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
|
|
105
|
+
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
106
|
+
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
107
|
+
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
108
|
+
findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
|
|
91
109
|
},
|
|
92
110
|
routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
|
|
93
111
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { ledgerState } from './ledger.js';
|
|
3
|
+
import { MIRROR_RESOURCES } from './mirror.js';
|
|
4
|
+
|
|
5
|
+
// What the core's matcher (server/core/pipeline.js) reads from Xero: the
|
|
6
|
+
// customers and the sales invoices in Flow's own copy. It never calls Xero, so
|
|
7
|
+
// matching a whole history costs no API calls.
|
|
8
|
+
|
|
9
|
+
// How old the mirror's last finished pass may be before "no invoice found"
|
|
10
|
+
// stops counting as proof.
|
|
11
|
+
export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export async function listCustomers(db, organisationId) {
|
|
14
|
+
const { rows } = await db.execute(sql`
|
|
15
|
+
select xero_contact_id, name, status from xero_contacts
|
|
16
|
+
where organisation_id = ${organisationId} and status = 'ACTIVE'
|
|
17
|
+
order by name`);
|
|
18
|
+
return rows.map((r) => ({ id: r.xero_contact_id, name: r.name }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Whether the mirror holds the whole history and was read lately. Until then a
|
|
22
|
+
// search that finds nothing proves nothing.
|
|
23
|
+
export async function mirrorState(db, organisationId, { now = new Date() } = {}) {
|
|
24
|
+
const { rows } = await db.execute(sql`
|
|
25
|
+
select resource, passes, last_pass_finished_at from xero_mirror_cursors
|
|
26
|
+
where organisation_id = ${organisationId}`);
|
|
27
|
+
const byResource = new Map(rows.map((r) => [r.resource, r]));
|
|
28
|
+
const invoices = byResource.get('invoices');
|
|
29
|
+
const finished = MIRROR_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
|
|
30
|
+
const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
|
|
31
|
+
const { rows: [changed] } = await db.execute(sql`
|
|
32
|
+
select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
|
|
33
|
+
return {
|
|
34
|
+
complete: finished,
|
|
35
|
+
fresh: Boolean(lastPass && now - lastPass <= MIRROR_FRESH_MS),
|
|
36
|
+
lastPassAt: lastPass,
|
|
37
|
+
// The latest change the mirror copied, so the matcher looks at a job again
|
|
38
|
+
// only when the mirror moved on.
|
|
39
|
+
changedAt: changed?.at ? new Date(changed.at) : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ids = (list) => sql.param([...new Set(list.map(String))]);
|
|
44
|
+
|
|
45
|
+
function invoiceRow(r) {
|
|
46
|
+
return {
|
|
47
|
+
id: r.xero_invoice_id,
|
|
48
|
+
number: r.invoice_number,
|
|
49
|
+
contactId: r.xero_contact_id,
|
|
50
|
+
status: r.status,
|
|
51
|
+
// The ledger's accounting state for this invoice.
|
|
52
|
+
state: ledgerState({ Status: r.status, AmountPaid: Number(r.amount_paid_cents ?? 0), AmountCredited: Number(r.amount_credited_cents ?? 0) }),
|
|
53
|
+
issuedOn: r.issued_on instanceof Date ? r.issued_on.toISOString().slice(0, 10) : r.issued_on,
|
|
54
|
+
subtotalCents: r.subtotal_cents === null ? null : Number(r.subtotal_cents),
|
|
55
|
+
totalCents: r.total_cents === null ? null : Number(r.total_cents),
|
|
56
|
+
amountDueCents: r.amount_due_cents === null ? null : Number(r.amount_due_cents),
|
|
57
|
+
amountPaidCents: r.amount_paid_cents === null ? null : Number(r.amount_paid_cents),
|
|
58
|
+
amountCreditedCents: r.amount_credited_cents === null ? null : Number(r.amount_credited_cents),
|
|
59
|
+
itemCodes: [...new Set((r.lines ?? []).map((l) => l.itemCode).filter(Boolean))],
|
|
60
|
+
flowInvoiceId: r.flow_invoice_id ?? null,
|
|
61
|
+
changedAt: r.last_changed_at,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sales invoices from the mirror, by any of: Xero ids, invoice numbers, the
|
|
66
|
+
// Flow invoices Flow sent, or one customer within a date range. Answers the
|
|
67
|
+
// mirror's state with them, and the latest change time it holds.
|
|
68
|
+
export async function findInvoices(db, organisationId, query = {}) {
|
|
69
|
+
const { invoiceIds = [], numbers = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
|
|
70
|
+
const where = [];
|
|
71
|
+
if (invoiceIds.length) where.push(sql`i.xero_invoice_id = any(${ids(invoiceIds)}::text[])`);
|
|
72
|
+
if (numbers.length) where.push(sql`upper(i.invoice_number) = any(${sql.param([...new Set(numbers.map((n) => String(n).toUpperCase()))])}::text[])`);
|
|
73
|
+
if (flowInvoiceIds.length) where.push(sql`p.invoice_id = any(${ids(flowInvoiceIds)}::uuid[])`);
|
|
74
|
+
if (contactId) {
|
|
75
|
+
where.push(sql`(i.xero_contact_id = ${contactId}
|
|
76
|
+
${from ? sql`and i.issued_on >= ${from}::date` : sql``}
|
|
77
|
+
${to ? sql`and i.issued_on <= ${to}::date` : sql``})`);
|
|
78
|
+
}
|
|
79
|
+
const state = await mirrorState(db, organisationId);
|
|
80
|
+
if (!where.length) return { ...state, invoices: [] };
|
|
81
|
+
const { rows } = await db.execute(sql`
|
|
82
|
+
select i.*, p.invoice_id as flow_invoice_id
|
|
83
|
+
from xero_invoices i
|
|
84
|
+
left join lateral (
|
|
85
|
+
select invoice_id from xero_invoice_pushes
|
|
86
|
+
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
87
|
+
order by version desc limit 1
|
|
88
|
+
) p on true
|
|
89
|
+
where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
|
|
90
|
+
and (${sql.join(where, sql` or `)})
|
|
91
|
+
order by i.issued_on, i.xero_invoice_id
|
|
92
|
+
limit 500`);
|
|
93
|
+
return { ...state, invoices: rows.map(invoiceRow) };
|
|
94
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- How each push was sent (invoicing_settings.send_mode) and, for an invoice
|
|
2
|
+
-- sent approved, whether Xero emailed it to the customer. Xero's email action
|
|
3
|
+
-- is not idempotent, so Flow records `sending` before the call and retries
|
|
4
|
+
-- only a refusal (`failed`), never an unknown outcome.
|
|
5
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js
|
|
2
|
-
// scripts/port-openclaw-themes.mjs).
|
|
1
|
+
// WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js,
|
|
2
|
+
// server/theme/contrast-audit.js and scripts/port-openclaw-themes.mjs).
|
|
3
|
+
// Colours are #rrggbb, except where parseColor reads a translucent one.
|
|
3
4
|
|
|
4
5
|
export const HEX = /^#[0-9a-f]{6}$/i;
|
|
5
6
|
|
|
@@ -37,6 +38,16 @@ export function readableOn(color, bg, target = 4.5) {
|
|
|
37
38
|
return toward[0] ? '#ffffff' : '#000000';
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** readableOn for every surface in turn, until the colour reads on all of them. */
|
|
42
|
+
export function readableOnEvery(color, surfaces, target = 4.5) {
|
|
43
|
+
let out = color;
|
|
44
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
45
|
+
for (const bg of surfaces) out = readableOn(out, bg, target);
|
|
46
|
+
if (surfaces.every((bg) => !HEX.test(out) || !HEX.test(bg) || contrast(out, bg) >= target)) break;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
/**
|
|
41
52
|
* A boundary colour for controls on `bg` (an input's or an outlined button's
|
|
42
53
|
* edge): `bg` mixed toward `ink` just enough to stand out at `target`
|
|
@@ -52,3 +63,29 @@ export function boundaryOn(bg, ink, target = 3) {
|
|
|
52
63
|
}
|
|
53
64
|
return ink;
|
|
54
65
|
}
|
|
66
|
+
|
|
67
|
+
/** [r, g, b, a] (0-255, alpha 0-1) for #rrggbb, rgb()/rgba() or color-mix(in srgb, X N%, transparent). */
|
|
68
|
+
export function parseColor(value) {
|
|
69
|
+
const v = String(value).trim();
|
|
70
|
+
let m = v.match(/^#([0-9a-f]{6})$/i);
|
|
71
|
+
if (m) return [0, 2, 4].map((i) => Number.parseInt(m[1].slice(i, i + 2), 16)).concat(1);
|
|
72
|
+
m = v.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/i);
|
|
73
|
+
if (m) {
|
|
74
|
+
const a = m[4] === undefined ? 1 : m[4].endsWith('%') ? Number.parseFloat(m[4]) / 100 : Number(m[4]);
|
|
75
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), a];
|
|
76
|
+
}
|
|
77
|
+
m = v.match(/^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*transparent\)$/i);
|
|
78
|
+
if (m) {
|
|
79
|
+
const [r, g, b, a] = parseColor(m[1]);
|
|
80
|
+
return [r, g, b, a * Number(m[2]) / 100];
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`contrast audit cannot read colour "${value}"`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `top` painted over `under` (each a colour string), as an opaque #rrggbb. */
|
|
86
|
+
export function over(top, under) {
|
|
87
|
+
const [r, g, b, a] = parseColor(top);
|
|
88
|
+
const base = parseColor(under);
|
|
89
|
+
if (base[3] < 1) throw new Error(`contrast audit needs an opaque surface, got "${under}"`);
|
|
90
|
+
return `#${[r, g, b].map((c, i) => Math.round(c * a + base[i] * (1 - a)).toString(16).padStart(2, '0')).join('')}`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The WCAG AA contrast audit for every design system (server/theme/themes.js).
|
|
2
|
+
// Each pair is a foreground a screen really draws on a surface it really sits
|
|
3
|
+
// on: Flow's components (server/ui/flow.css) and Clerk's account and sign-in
|
|
4
|
+
// screens (server/ui/clerk-appearance.js). Translucent tokens (rgba() and
|
|
5
|
+
// color-mix() toward transparent) are laid over the surface they sit on before
|
|
6
|
+
// the ratio is taken, the way a browser paints them. test/theme-contrast.test.js
|
|
7
|
+
// runs it in CI; `npm run theme:contrast` prints the table.
|
|
8
|
+
import { contrast, over, parseColor } from './color-contrast.js';
|
|
9
|
+
|
|
10
|
+
// WCAG 2.1: 4.5:1 for text (1.4.3), 3:1 for the edge or fill that shows a
|
|
11
|
+
// control or its state (1.4.11).
|
|
12
|
+
export const TEXT = 4.5;
|
|
13
|
+
export const UI = 3;
|
|
14
|
+
|
|
15
|
+
function withAlpha(color, alpha) {
|
|
16
|
+
const [r, g, b, a] = parseColor(color);
|
|
17
|
+
return `rgba(${r}, ${g}, ${b}, ${a * alpha})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// [id, what it is, minimum, (tokens) => [foreground, surface]]. A surface
|
|
21
|
+
// given as an array is laid bottom first, so a tinted badge on a card is
|
|
22
|
+
// [paper, tint].
|
|
23
|
+
const PAIRS = [
|
|
24
|
+
['text/bg', 'Body text on the page', TEXT, (k) => [k['--t-text'], k['--t-bg']]],
|
|
25
|
+
['text/paper', 'Body text on a card', TEXT, (k) => [k['--t-text'], k['--t-paper']]],
|
|
26
|
+
['strong/paper', 'Headings and names on a card', TEXT, (k) => [k['--t-strong'], k['--t-paper']]],
|
|
27
|
+
['strong/bg', 'Page titles on the page', TEXT, (k) => [k['--t-strong'], k['--t-bg']]],
|
|
28
|
+
['dim/bg', 'Secondary text on the page', TEXT, (k) => [k['--t-dim'], k['--t-bg']]],
|
|
29
|
+
['dim/paper', 'Secondary text on a card (Clerk muted text)', TEXT, (k) => [k['--t-dim'], k['--t-paper']]],
|
|
30
|
+
['dim/s1', 'Table headings, Clerk side menu', TEXT, (k) => [k['--t-dim'], k['--t-s1']]],
|
|
31
|
+
['dim/s2', 'Segmented control labels', TEXT, (k) => [k['--t-dim'], k['--t-s2']]],
|
|
32
|
+
['mute/paper', 'Muted text on a card', TEXT, (k) => [k['--t-mute'], k['--t-paper']]],
|
|
33
|
+
['accent/bg', 'Links on the page', TEXT, (k) => [k['--t-accent'], k['--t-bg']]],
|
|
34
|
+
['accent/paper', 'Links on a card', TEXT, (k) => [k['--t-accent'], k['--t-paper']]],
|
|
35
|
+
['accent/accent-bg', 'Status and role badges', TEXT, (k) => [k['--t-accent'], [k['--t-paper'], k['--t-accent-bg']]]],
|
|
36
|
+
['strong/accent-bg', 'Current page in the menu', TEXT, (k) => [k['--t-strong'], [k['--t-bg'], k['--t-accent-bg']]]],
|
|
37
|
+
['text/accent-bg', 'Notices', TEXT, (k) => [k['--t-text'], [k['--t-paper'], k['--t-accent-bg']]]],
|
|
38
|
+
['on-primary/primary', 'Primary buttons', TEXT, (k) => [k['--t-on-primary'], k['--t-primary']]],
|
|
39
|
+
['brand-ink/brand', 'Text on the brand colour', TEXT, (k) => [k['--t-brand-ink'], k['--t-brand']]],
|
|
40
|
+
['success/success-bg', 'Success badges', TEXT, (k) => [k['--t-success'], [k['--t-paper'], k['--t-success-bg']]]],
|
|
41
|
+
['warn/warn-bg', 'Warning badges', TEXT, (k) => [k['--t-warn'], [k['--t-paper'], k['--t-warn-bg']]]],
|
|
42
|
+
['danger/danger-bg', 'Error notices', TEXT, (k) => [k['--t-danger'], [k['--t-paper'], k['--t-danger-bg']]]],
|
|
43
|
+
['paper/danger', 'Danger buttons and error toasts', TEXT, (k) => [k['--t-paper'], k['--t-danger']]],
|
|
44
|
+
['bg/strong', 'Toasts', TEXT, (k) => [k['--t-bg'], k['--t-strong']]],
|
|
45
|
+
// server/ui/clerk-appearance.js: badge.
|
|
46
|
+
['clerk-badge', 'Clerk "Primary" badge by an email', TEXT, (k) => [k['--t-dim'], [k['--t-paper'], withAlpha(k['--t-text'], 0.05)]]],
|
|
47
|
+
['control/paper', 'Input and outlined button edges', UI, (k) => [k['--t-control'], k['--t-paper']]],
|
|
48
|
+
['ring/paper', 'Focus ring', UI, (k) => [k['--t-ring'], k['--t-paper']]],
|
|
49
|
+
['brand/paper', 'Selected theme card ring', UI, (k) => [k['--t-brand'], k['--t-paper']]],
|
|
50
|
+
['primary/paper', 'Primary button edge on a card', UI, (k) => [k['--t-primary'], k['--t-paper']]],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Every pair for one family and mode: { id, label, min, fg, bg, ratio, pass }. */
|
|
54
|
+
export function auditTokens(tokens) {
|
|
55
|
+
return PAIRS.map(([id, label, min, pick]) => {
|
|
56
|
+
const [fgRaw, surface] = pick(tokens);
|
|
57
|
+
const layers = Array.isArray(surface) ? surface : [surface];
|
|
58
|
+
const bg = layers.slice(1).reduce((under, top) => over(top, under), over(layers[0], '#ffffff'));
|
|
59
|
+
const fg = over(fgRaw, bg);
|
|
60
|
+
const ratio = contrast(fg, bg);
|
|
61
|
+
return { id, label, min, fg, bg, ratio: Math.round(ratio * 100) / 100, pass: ratio >= min };
|
|
62
|
+
});
|
|
63
|
+
}
|
package/server/theme/themes.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ./openclaw-palettes.js (docs/DESIGN.md). The engine is Arc Forge Labs'
|
|
10
10
|
* Field's, so a family looks the same in both apps.
|
|
11
11
|
*/
|
|
12
|
-
import { boundaryOn, HEX, inkOn, readableOn } from './color-contrast.js';
|
|
12
|
+
import { boundaryOn, contrast, HEX, inkOn, over, readableOn, readableOnEvery } from './color-contrast.js';
|
|
13
13
|
import { OPENCLAW_FAMILIES } from './openclaw-palettes.js';
|
|
14
14
|
|
|
15
15
|
export { boundaryOn, contrast, inkOn, readableOn } from './color-contrast.js';
|
|
@@ -125,12 +125,20 @@ const MERIDIAN = {
|
|
|
125
125
|
const { beacon, tide, claw, knot, dash, ...otherOpenclaw } = OPENCLAW_FAMILIES;
|
|
126
126
|
const FAMILIES = { harbour: HARBOUR, meridian: MERIDIAN, beacon, tide, claw, knot, dash, ...otherOpenclaw };
|
|
127
127
|
|
|
128
|
+
// Every family keeps WCAG AA (test/theme-contrast.test.js). A ported palette
|
|
129
|
+
// that misses it on a surface Flow uses is nudged here, just far enough:
|
|
130
|
+
// secondary text on every surface, accent text on the page, cards and its own
|
|
131
|
+
// tint, and the ink on the brand colour. Harbour and Meridian already pass.
|
|
128
132
|
// --t-control: the edge of an input or an outlined button on --t-paper, the
|
|
129
133
|
// text colour faded toward the paper only as far as it still reads at 3:1.
|
|
130
134
|
for (const family of Object.values(FAMILIES)) {
|
|
131
135
|
for (const mode of ['light', 'dark']) {
|
|
132
136
|
const tokens = family[mode];
|
|
133
|
-
|
|
137
|
+
const { '--t-bg': bg, '--t-paper': paper, '--t-s1': s1, '--t-s2': s2 } = tokens;
|
|
138
|
+
tokens['--t-dim'] = readableOnEvery(tokens['--t-dim'], [bg, paper, s1, s2]);
|
|
139
|
+
tokens['--t-accent'] = readableOnEvery(tokens['--t-accent'], [bg, paper, over(tokens['--t-accent-bg'], paper)]);
|
|
140
|
+
if (contrast(tokens['--t-brand-ink'], tokens['--t-brand']) < 4.5) tokens['--t-brand-ink'] = inkOn(tokens['--t-brand']);
|
|
141
|
+
tokens['--t-control'] = boundaryOn(paper, tokens['--t-text']);
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
|