@arcforgelabs/flow 2026.10.24 → 2026.10.26
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 +2 -1
- package/VISION.md +56 -15
- package/npm-shrinkwrap.json +12 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +375 -0
- package/server/app.js +11 -0
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/invoices.js +7 -0
- package/server/core/ledger.js +6 -3
- package/server/core/links.js +3 -3
- package/server/core/pipeline.js +488 -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/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +71 -2
- package/server/index.js +9 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +10 -2
- package/server/integrations/xero/lookup.js +94 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/font-families.js +1 -0
- package/server/theme/themes.js +67 -5
- package/server/ui/app-page.js +5 -10
- package/server/ui/app.js +351 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +246 -22
- package/server/ui/invoicing.js +451 -0
- package/server/ui/jobs.js +153 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/routes.js +5 -1
|
@@ -57,6 +57,20 @@
|
|
|
57
57
|
"when": 1792391146736,
|
|
58
58
|
"tag": "0007_integration_settings",
|
|
59
59
|
"breakpoints": true
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"idx": 8,
|
|
63
|
+
"version": "7",
|
|
64
|
+
"when": 1792396463612,
|
|
65
|
+
"tag": "0008_people_appearance",
|
|
66
|
+
"breakpoints": true
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"idx": 9,
|
|
70
|
+
"version": "7",
|
|
71
|
+
"when": 1792404377371,
|
|
72
|
+
"tag": "0009_pipeline_matcher",
|
|
73
|
+
"breakpoints": true
|
|
60
74
|
}
|
|
61
75
|
]
|
|
62
76
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
integer,
|
|
10
10
|
jsonb,
|
|
11
11
|
pgTable,
|
|
12
|
+
primaryKey,
|
|
12
13
|
text,
|
|
13
14
|
timestamp,
|
|
14
15
|
unique,
|
|
@@ -153,7 +154,9 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
|
|
|
153
154
|
// someone is; this table decides what they may do. One installation serves one
|
|
154
155
|
// lab, so people are installation-wide and carry no organisation_id. Emails
|
|
155
156
|
// are stored lower-case. `clerk_user_id` is set on the person's first verified
|
|
156
|
-
// sign-in; until then the row is an invitation.
|
|
157
|
+
// sign-in; until then the row is an invitation. `appearance` is the person's
|
|
158
|
+
// own design system, light or dark and accent (server/auth/people.js,
|
|
159
|
+
// normaliseAppearance); null until they first choose.
|
|
157
160
|
export const people = pgTable('people', {
|
|
158
161
|
id: id(),
|
|
159
162
|
email: text('email').notNull(),
|
|
@@ -163,6 +166,7 @@ export const people = pgTable('people', {
|
|
|
163
166
|
clerkUserId: text('clerk_user_id'),
|
|
164
167
|
invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
|
|
165
168
|
linkedAt: timestamp('linked_at', { withTimezone: true }),
|
|
169
|
+
appearance: jsonb('appearance'),
|
|
166
170
|
createdAt: createdAt(),
|
|
167
171
|
updatedAt: updatedAt(),
|
|
168
172
|
}, (t) => [
|
|
@@ -202,6 +206,9 @@ export const billingObligations = pgTable('billing_obligations', {
|
|
|
202
206
|
contactId: uuid('contact_id'),
|
|
203
207
|
practitioner: text('practitioner'),
|
|
204
208
|
shippedOn: date('shipped_on'),
|
|
209
|
+
// The day of the work, for matching it to an accounting invoice: the work
|
|
210
|
+
// system's invoice date, else its due date, else its order date.
|
|
211
|
+
jobDate: date('job_date'),
|
|
205
212
|
// The lines as the work system reported them, normalised.
|
|
206
213
|
lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
|
|
207
214
|
lineFingerprint: text('line_fingerprint').notNull(),
|
|
@@ -316,7 +323,9 @@ export const obligationWritebacks = pgTable('obligation_writebacks', {
|
|
|
316
323
|
// one active link per external record and per Flow record, in each system. A
|
|
317
324
|
// link that is not exact stays `proposed` until a person confirms it.
|
|
318
325
|
export const LINK_ENTITIES = ['contact', 'price_item', 'obligation', 'invoice', 'payment', 'credit_note'];
|
|
319
|
-
|
|
326
|
+
// `exact_name`: the same name, after spacing and case are evened out, held by
|
|
327
|
+
// exactly one record on each side.
|
|
328
|
+
export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'exact_name', 'imported', 'name', 'person'];
|
|
320
329
|
export const LINK_STATES = ['active', 'proposed', 'superseded'];
|
|
321
330
|
|
|
322
331
|
export const externalLinks = pgTable('external_links', {
|
|
@@ -490,3 +499,63 @@ export const integrationSettingEvents = pgTable('integration_setting_events', {
|
|
|
490
499
|
index('integration_setting_events_adapter').on(t.adapterId, t.createdAt),
|
|
491
500
|
check('integration_setting_events_kind_valid', inList('kind', INTEGRATION_SETTING_EVENTS)),
|
|
492
501
|
]);
|
|
502
|
+
|
|
503
|
+
// The pipeline (server/core/pipeline.js, issue 58). Where each step that reads
|
|
504
|
+
// another system's records stands, by name, so a restart goes on from there.
|
|
505
|
+
export const pipelineCursors = pgTable('pipeline_cursors', {
|
|
506
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
507
|
+
name: text('name').notNull(),
|
|
508
|
+
cursor: text('cursor'),
|
|
509
|
+
updatedAt: updatedAt(),
|
|
510
|
+
}, (t) => [
|
|
511
|
+
primaryKey({ name: 'pipeline_cursors_pk', columns: [t.organisationId, t.name] }),
|
|
512
|
+
]);
|
|
513
|
+
|
|
514
|
+
// The matcher's suggestions: accounting invoices that could bill a job, with
|
|
515
|
+
// the signals that agreed. Only exact evidence links a job by itself; a
|
|
516
|
+
// suggestion waits for a person. A newer run marks the ones it no longer
|
|
517
|
+
// makes `stale`.
|
|
518
|
+
export const LINK_SUGGESTION_STATES = ['open', 'accepted', 'rejected', 'stale'];
|
|
519
|
+
|
|
520
|
+
export const linkSuggestions = pgTable('link_suggestions', {
|
|
521
|
+
id: id(),
|
|
522
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
523
|
+
obligationId: uuid('obligation_id').notNull(),
|
|
524
|
+
system: text('system').notNull(),
|
|
525
|
+
externalId: text('external_id').notNull(),
|
|
526
|
+
externalNumber: text('external_number'),
|
|
527
|
+
score: integer('score').notNull(),
|
|
528
|
+
rank: integer('rank').notNull(),
|
|
529
|
+
signals: jsonb('signals').notNull().default(sql`'[]'::jsonb`),
|
|
530
|
+
state: text('state').notNull().default('open'),
|
|
531
|
+
decidedBy: text('decided_by'),
|
|
532
|
+
decidedAt: timestamp('decided_at', { withTimezone: true }),
|
|
533
|
+
createdAt: createdAt(),
|
|
534
|
+
updatedAt: updatedAt(),
|
|
535
|
+
}, (t) => [
|
|
536
|
+
unique('link_suggestions_pair').on(t.organisationId, t.obligationId, t.system, t.externalId),
|
|
537
|
+
index('link_suggestions_open').on(t.organisationId, t.state, t.obligationId),
|
|
538
|
+
foreignKey({
|
|
539
|
+
name: 'link_suggestions_obligation_same_org',
|
|
540
|
+
columns: [t.organisationId, t.obligationId],
|
|
541
|
+
foreignColumns: [billingObligations.organisationId, billingObligations.id],
|
|
542
|
+
}),
|
|
543
|
+
check('link_suggestions_state_valid', inList('state', LINK_SUGGESTION_STATES)),
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
// A person's "not this one": the matcher never suggests the pair again.
|
|
547
|
+
export const linkRejections = pgTable('link_rejections', {
|
|
548
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
549
|
+
obligationId: uuid('obligation_id').notNull(),
|
|
550
|
+
system: text('system').notNull(),
|
|
551
|
+
externalId: text('external_id').notNull(),
|
|
552
|
+
actor: text('actor').notNull(),
|
|
553
|
+
createdAt: createdAt(),
|
|
554
|
+
}, (t) => [
|
|
555
|
+
primaryKey({ name: 'link_rejections_pk', columns: [t.organisationId, t.obligationId, t.system, t.externalId] }),
|
|
556
|
+
foreignKey({
|
|
557
|
+
name: 'link_rejections_obligation_same_org',
|
|
558
|
+
columns: [t.organisationId, t.obligationId],
|
|
559
|
+
foreignColumns: [billingObligations.organisationId, billingObligations.id],
|
|
560
|
+
}),
|
|
561
|
+
]);
|
package/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createPersonAuth } from './auth/session.js';
|
|
|
6
6
|
import { resolveSignInSettings } from './auth/settings.js';
|
|
7
7
|
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
8
8
|
import { importEarlierAtStart, importEarlierLinksAtStart, resolveImportAtStart } from './import/at-start.js';
|
|
9
|
+
import { createPipelineWorker } from './core/pipeline.js';
|
|
9
10
|
import { resolveSecretsKey } from './core/secrets.js';
|
|
10
11
|
import { adapters as shippedAdapters } from './integrations/adapters.js';
|
|
11
12
|
import { createIntegrations, resolveEnabledIntegrations } from './integrations/index.js';
|
|
@@ -79,6 +80,14 @@ try {
|
|
|
79
80
|
const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
|
|
80
81
|
console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
|
|
81
82
|
|
|
83
|
+
// The pipeline (server/core/pipeline.js) joins what the integrations copy into
|
|
84
|
+
// Flow: jobs into the ledger, and each job to its accounting invoice. It runs
|
|
85
|
+
// only while an integration that feeds it is on, and writes to no other
|
|
86
|
+
// system. FLOW_PIPELINE=off stops it.
|
|
87
|
+
if (process.env.FLOW_PIPELINE !== 'off') {
|
|
88
|
+
createPipelineWorker({ db: database.db, integrations }).start();
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
let auth = null;
|
|
83
92
|
if (settings.mode === 'clerk') {
|
|
84
93
|
const { keys } = settings;
|
|
@@ -14,6 +14,9 @@ export const CAPABILITIES = Object.freeze([
|
|
|
14
14
|
'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
|
|
15
15
|
'pushBill', // send an approved bill; the Flow record id is the idempotency key
|
|
16
16
|
'pullPayments', // payments since a cursor, matched to invoices and bills
|
|
17
|
+
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
18
|
+
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
19
|
+
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
17
20
|
'health', // connection state for Settings
|
|
18
21
|
]);
|
|
19
22
|
|
|
@@ -102,3 +102,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
|
|
|
102
102
|
more: rows.length === pageSize,
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
// Seazona clients, by id and the company name an invoice bills. A client
|
|
107
|
+
// with no company name is listed by its full name.
|
|
108
|
+
export async function listClients(db) {
|
|
109
|
+
const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
|
|
110
|
+
.from(seazonaClients);
|
|
111
|
+
return rows
|
|
112
|
+
.filter((r) => r.active !== false)
|
|
113
|
+
.map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
|
|
114
|
+
.filter((r) => r.name);
|
|
115
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSeazonaClient } from './client.js';
|
|
2
|
-
import { readBillableWork } from './feed.js';
|
|
2
|
+
import { listClients, readBillableWork } from './feed.js';
|
|
3
3
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
4
4
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
5
5
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
@@ -99,6 +99,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
99
99
|
async pullWorkItems(cursor = null, options = {}) {
|
|
100
100
|
return readBillableWork(current().db, { cursor, ...options });
|
|
101
101
|
},
|
|
102
|
+
|
|
103
|
+
// Seazona clients by id and company name, for the identity map. Reads
|
|
104
|
+
// Flow's copy only.
|
|
105
|
+
async listCustomers() {
|
|
106
|
+
return listClients(current().db);
|
|
107
|
+
},
|
|
102
108
|
},
|
|
103
109
|
};
|
|
104
110
|
}
|
|
@@ -46,6 +46,8 @@ export function toLedgerJob(item) {
|
|
|
46
46
|
practitioner: item.doctorName ?? null,
|
|
47
47
|
// The Seazona API gives a shipped status but no shipped date.
|
|
48
48
|
shippedOn: null,
|
|
49
|
+
// The day of the work, for matching it to its accounting invoice.
|
|
50
|
+
jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
|
|
49
51
|
lines: ledgerLinesFor(item).lines,
|
|
50
52
|
observedAt: item.syncedAt,
|
|
51
53
|
actor: SEAZONA_SOURCE_SYSTEM,
|
|
@@ -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;
|
|
@@ -2,7 +2,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
3
|
import { pushInvoice } from './drafts.js';
|
|
4
4
|
import { createImportWorker } from './import.js';
|
|
5
|
-
import { readPayments } from './ledger.js';
|
|
5
|
+
import { readPayments, xeroLiveCheck } from './ledger.js';
|
|
6
|
+
import { findInvoices, listCustomers } from './lookup.js';
|
|
6
7
|
import { createXeroRouter } from './routes.js';
|
|
7
8
|
import { createXeroRuntime, requireXeroSettings } from './runtime.js';
|
|
8
9
|
import { writesAllowed } from './settings.js';
|
|
@@ -30,7 +31,7 @@ export const XERO_SETUP = {
|
|
|
30
31
|
},
|
|
31
32
|
fields: [
|
|
32
33
|
{ 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: '
|
|
34
|
+
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
|
|
34
35
|
{ name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
|
|
35
36
|
{ name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
36
37
|
{ name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
|
|
@@ -88,6 +89,13 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
88
89
|
// Sales invoice payments changed since `since` (ledger.js). The answer's
|
|
89
90
|
// cursor is the next `since`.
|
|
90
91
|
pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
|
|
92
|
+
// Xero's invoice for one billable job, looked up live (ledger.js): the
|
|
93
|
+
// `liveCheck` for draftInvoiceForObligation.
|
|
94
|
+
checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
|
|
95
|
+
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
96
|
+
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
97
|
+
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
98
|
+
findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
|
|
91
99
|
},
|
|
92
100
|
routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
|
|
93
101
|
};
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,7 @@ export const MONO_FONT = 'jetbrains-mono';
|
|
|
6
6
|
|
|
7
7
|
export const FAMILY_FONTS = {
|
|
8
8
|
harbour: ['nunito-sans', 'playfair-display'],
|
|
9
|
+
meridian: ['plus-jakarta-sans', 'playfair-display'],
|
|
9
10
|
beacon: ['atkinson-hyperlegible-next'],
|
|
10
11
|
tide: ['ibm-plex-sans'],
|
|
11
12
|
claw: ['instrument-sans'],
|
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';
|
|
@@ -66,17 +66,79 @@ const HARBOUR = {
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
69
|
+
// Meridian: a dental laboratory's own look. Deep navy ink and filled buttons,
|
|
70
|
+
// a champagne highlight, warm stone pages and white cards. Plus Jakarta Sans
|
|
71
|
+
// for reading, Playfair Display at regular weight for page titles, square
|
|
72
|
+
// corners and widely tracked uppercase labels. Dark mode turns the navy into
|
|
73
|
+
// the page and the champagne into the accent.
|
|
74
|
+
const MERIDIAN_SHAPE = {
|
|
75
|
+
'--t-body': "'Plus Jakarta Sans Variable', 'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
76
|
+
'--t-display': "'Playfair Display Variable', 'Playfair Display', Georgia, 'Times New Roman', serif",
|
|
77
|
+
'--t-data': "'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
78
|
+
'--t-radius': '0px',
|
|
79
|
+
'--t-radius-lg': '2px',
|
|
80
|
+
'--t-stroke': '1px',
|
|
81
|
+
'--t-label-size': '12px',
|
|
82
|
+
'--t-label-weight': '600',
|
|
83
|
+
'--t-label-spacing': '.16em',
|
|
84
|
+
'--t-label-case': 'uppercase',
|
|
85
|
+
'--t-display-weight': '400',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const MERIDIAN = {
|
|
89
|
+
label: 'Meridian',
|
|
90
|
+
description: 'Navy, champagne & stone',
|
|
91
|
+
source: 'flow',
|
|
92
|
+
light: {
|
|
93
|
+
'--t-bg': '#f6f3ef', '--t-s1': '#efe9e0', '--t-s2': '#e6ded1', '--t-paper': '#ffffff',
|
|
94
|
+
'--t-brd': '#e0dbd1', '--t-brd-hi': '#b8a685', '--t-stroke-color': '#d5cec2',
|
|
95
|
+
'--t-text': '#1d3444', '--t-strong': '#13242f', '--t-dim': '#4d6575', '--t-mute': '#526a7a',
|
|
96
|
+
'--t-accent': '#1d3444', '--t-accent-bg': '#faefdd', '--t-accent-brd': '#d4b07a',
|
|
97
|
+
'--t-brand': '#1d3444', '--t-brand-ink': '#f8dfb9',
|
|
98
|
+
'--t-primary': '#1d3444', '--t-on-primary': '#f8dfb9', '--t-ring': '#b08850',
|
|
99
|
+
'--t-warn': '#8a5a00', '--t-warn-bg': 'rgba(138, 90, 0, 0.08)', '--t-warn-brd': 'rgba(138, 90, 0, 0.32)',
|
|
100
|
+
'--t-danger': '#b42318', '--t-danger-bg': 'rgba(180, 35, 24, 0.07)', '--t-danger-brd': 'rgba(180, 35, 24, 0.40)',
|
|
101
|
+
'--t-success': '#22663f', '--t-success-bg': 'rgba(34, 102, 63, 0.08)',
|
|
102
|
+
...MERIDIAN_SHAPE,
|
|
103
|
+
'--t-shadow': '0 1px 2px rgba(29, 52, 68, 0.06)',
|
|
104
|
+
'color-scheme': 'light',
|
|
105
|
+
},
|
|
106
|
+
dark: {
|
|
107
|
+
'--t-bg': '#14252f', '--t-s1': '#1d3444', '--t-s2': '#26404f', '--t-paper': '#192d3a',
|
|
108
|
+
'--t-brd': '#2c4555', '--t-brd-hi': '#56707f', '--t-stroke-color': '#334d5e',
|
|
109
|
+
'--t-text': '#ece5d8', '--t-strong': '#fbf4e8', '--t-dim': '#b9c3c9', '--t-mute': '#a3b2bb',
|
|
110
|
+
'--t-accent': '#f8dfb9', '--t-accent-bg': 'rgba(248, 223, 185, 0.12)', '--t-accent-brd': 'rgba(248, 223, 185, 0.45)',
|
|
111
|
+
'--t-brand': '#f8dfb9', '--t-brand-ink': '#1d3444',
|
|
112
|
+
'--t-primary': '#f8dfb9', '--t-on-primary': '#1d3444', '--t-ring': '#f8dfb9',
|
|
113
|
+
'--t-warn': '#e7b75c', '--t-warn-bg': 'rgba(231, 183, 92, 0.12)', '--t-warn-brd': 'rgba(231, 183, 92, 0.32)',
|
|
114
|
+
'--t-danger': '#f2877b', '--t-danger-bg': 'rgba(242, 135, 123, 0.12)', '--t-danger-brd': 'rgba(242, 135, 123, 0.45)',
|
|
115
|
+
'--t-success': '#86d3a1', '--t-success-bg': 'rgba(134, 211, 161, 0.12)',
|
|
116
|
+
...MERIDIAN_SHAPE,
|
|
117
|
+
'--t-shadow': '0 1px 2px rgba(0, 0, 0, 0.30)',
|
|
118
|
+
'color-scheme': 'dark',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Picker order: Flow's own (Harbour, Meridian), then the design directions
|
|
123
|
+
// Field leads with (Beacon, Tide, Claw, Knot, Dash), then the other OpenClaw
|
|
124
|
+
// families.
|
|
71
125
|
const { beacon, tide, claw, knot, dash, ...otherOpenclaw } = OPENCLAW_FAMILIES;
|
|
72
|
-
const FAMILIES = { harbour: HARBOUR, beacon, tide, claw, knot, dash, ...otherOpenclaw };
|
|
126
|
+
const FAMILIES = { harbour: HARBOUR, meridian: MERIDIAN, beacon, tide, claw, knot, dash, ...otherOpenclaw };
|
|
73
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.
|
|
74
132
|
// --t-control: the edge of an input or an outlined button on --t-paper, the
|
|
75
133
|
// text colour faded toward the paper only as far as it still reads at 3:1.
|
|
76
134
|
for (const family of Object.values(FAMILIES)) {
|
|
77
135
|
for (const mode of ['light', 'dark']) {
|
|
78
136
|
const tokens = family[mode];
|
|
79
|
-
|
|
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']);
|
|
80
142
|
}
|
|
81
143
|
}
|
|
82
144
|
|