biz-a-cli 2.3.80-15339 → 2.3.80-15359
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/bin/app.js +50 -2
- package/bin/hubEvent.js +137 -0
- package/engine/domain/contrast.js +224 -0
- package/engine/domain/syncThemePresets.js +33 -0
- package/engine/domain/theme-presets.json +190 -0
- package/engine/domain/themeConfig.js +201 -0
- package/engine/domain/themePresets.js +80 -0
- package/engine/orm/companyLogo.js +206 -0
- package/engine/orm/companyTheme.js +169 -0
- package/engine/orm/executeBlock.js +53 -3
- package/engine/orm/principalGuards.js +51 -0
- package/engine/orm/userPreferences.js +113 -0
- package/migrations/1789600000000__user_preferences.sql +38 -0
- package/migrations/1789700000000__theme_audit.sql +46 -0
- package/migrations/postgres/1789600000000__user_preferences.sql +36 -0
- package/migrations/postgres/1789700000000__theme_audit.sql +44 -0
- package/package.json +1 -1
package/bin/app.js
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
import { prepareScript, encryptScript } from "./script.js";
|
|
21
21
|
import { spawn } from "node:child_process";
|
|
22
22
|
import { newMigration, runMigrations } from "./migrate.js";
|
|
23
|
+
import {
|
|
24
|
+
validateThemeSource,
|
|
25
|
+
APPLICATION_THEME_NAME,
|
|
26
|
+
} from "../engine/domain/themeConfig.js";
|
|
23
27
|
import {
|
|
24
28
|
APP_CONFIG_NAME,
|
|
25
29
|
buildEnsureConfigTableSql,
|
|
@@ -367,6 +371,9 @@ const getFileList = ({ workingDir = process.cwd(), files = null } = {}) => {
|
|
|
367
371
|
============================================================================ */
|
|
368
372
|
|
|
369
373
|
const APPLICATION_CONFIG_FILE = "application.js";
|
|
374
|
+
/* Doc 6 §8.1 — the application theme source. JSON, because it is DATA that reaches the browser
|
|
375
|
+
via sys$config and must never be evaluated (AC 26). */
|
|
376
|
+
const THEME_CONFIG_FILE = "theme.json";
|
|
370
377
|
/* Parity with the admin editor (appConfig.component.ts executeBlockBodyLimit): the DataSnap
|
|
371
378
|
`block` call body must stay under 64KB, so an oversized config is upserted in chunks. */
|
|
372
379
|
const EXECUTE_BLOCK_BODY_LIMIT = 2 ** 16;
|
|
@@ -548,6 +555,41 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
|
|
|
548
555
|
application = { name: APP_CONFIG_NAME, version, content };
|
|
549
556
|
}
|
|
550
557
|
|
|
558
|
+
/*
|
|
559
|
+
* Doc 6 §8.1 — the application's theme, published beside its config.
|
|
560
|
+
*
|
|
561
|
+
* ⚠️ A SEPARATE FILE, AND JSON, for the reason §8.1 points 1-2 give: this ends up in
|
|
562
|
+
* `sys$config` and is read by every user's browser, so if it were executable anyone who can
|
|
563
|
+
* write `sys$config` could run code in all of them. It is parsed, never evaluated (AC 26).
|
|
564
|
+
*
|
|
565
|
+
* ⚠️ Validated at PUBLISH, not at runtime. A mistyped token name is silent at runtime — the
|
|
566
|
+
* token is simply never applied — and the author is left believing the theme works. Refusing the
|
|
567
|
+
* publish puts the error where somebody can act on it (§8.1 point 4).
|
|
568
|
+
*/
|
|
569
|
+
let theme = null;
|
|
570
|
+
const themeFile = fileNames.find(
|
|
571
|
+
(f) => path.basename(String(f)).toLowerCase() === THEME_CONFIG_FILE,
|
|
572
|
+
);
|
|
573
|
+
if (themeFile) {
|
|
574
|
+
const { theme: parsedTheme, problems: themeProblems } = validateThemeSource(
|
|
575
|
+
readConfig(themeFile),
|
|
576
|
+
);
|
|
577
|
+
if (themeProblems.length) {
|
|
578
|
+
problems.push(
|
|
579
|
+
...themeProblems.map((m) => `${path.basename(String(themeFile))}: ${m}`),
|
|
580
|
+
);
|
|
581
|
+
} else {
|
|
582
|
+
/* Versioned with the application: the theme belongs to that release of the app, and a
|
|
583
|
+
rollback of one without the other would pair a config with a theme it never shipped
|
|
584
|
+
with. */
|
|
585
|
+
theme = {
|
|
586
|
+
name: APPLICATION_THEME_NAME,
|
|
587
|
+
version: application?.version ?? "1.0.0",
|
|
588
|
+
content: JSON.stringify(parsedTheme),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
551
593
|
const domains = [];
|
|
552
594
|
/* ⚠️ Kept because the tenancy rule is CROSS-FILE: a scoped entity in one domain may be given its
|
|
553
595
|
foreign key by a relationship in another, and `buildTenancyMap` folds them all together at
|
|
@@ -610,7 +652,7 @@ const collectConfigUploads = ({ workingDir, fileNames = [], include = [] }) => {
|
|
|
610
652
|
);
|
|
611
653
|
}
|
|
612
654
|
|
|
613
|
-
return { application, domains };
|
|
655
|
+
return { application, domains, theme };
|
|
614
656
|
};
|
|
615
657
|
|
|
616
658
|
/* Upsert the configs, then optionally run the same publish the admin button does. Publish order
|
|
@@ -693,7 +735,7 @@ async function uploadConfigs({
|
|
|
693
735
|
serverLink: resolveServerLink(serverMode),
|
|
694
736
|
});
|
|
695
737
|
|
|
696
|
-
const { application, domains } = collectConfigUploads({
|
|
738
|
+
const { application, domains, theme } = collectConfigUploads({
|
|
697
739
|
workingDir,
|
|
698
740
|
fileNames,
|
|
699
741
|
include,
|
|
@@ -752,6 +794,12 @@ async function uploadConfigs({
|
|
|
752
794
|
content: application.content,
|
|
753
795
|
});
|
|
754
796
|
}
|
|
797
|
+
|
|
798
|
+
/* Doc 6 §8.1 — level 2. ⚠️ A DIFFERENT KEY from the company's own theme (level 3): sharing one
|
|
799
|
+
would mean publishing an app silently overwrote a company's branding. */
|
|
800
|
+
if (theme) {
|
|
801
|
+
await saveOne(theme);
|
|
802
|
+
}
|
|
755
803
|
for (const domain of domains) {
|
|
756
804
|
await saveOne(domain);
|
|
757
805
|
}
|
package/bin/hubEvent.js
CHANGED
|
@@ -24,6 +24,19 @@ import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
|
24
24
|
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
25
25
|
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
26
26
|
import { authorizeRequest, principalForCliScript } from "../security/internalPrincipal.js";
|
|
27
|
+
import {
|
|
28
|
+
readUserPreferences,
|
|
29
|
+
writeUserPreferences,
|
|
30
|
+
} from "../engine/orm/userPreferences.js";
|
|
31
|
+
import {
|
|
32
|
+
readCompanyTheme,
|
|
33
|
+
writeCompanyTheme,
|
|
34
|
+
} from "../engine/orm/companyTheme.js";
|
|
35
|
+
import {
|
|
36
|
+
readCompanyLogo,
|
|
37
|
+
writeCompanyLogo,
|
|
38
|
+
clearCompanyLogo,
|
|
39
|
+
} from "../engine/orm/companyLogo.js";
|
|
27
40
|
import {
|
|
28
41
|
execPostgres,
|
|
29
42
|
execPostgresStatements,
|
|
@@ -508,6 +521,130 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
508
521
|
),
|
|
509
522
|
);
|
|
510
523
|
break;
|
|
524
|
+
/*
|
|
525
|
+
* Doc 6 §4.2 / AC 19 — read or merge the SIGNED-IN USER'S preferences.
|
|
526
|
+
*
|
|
527
|
+
* ⚠️ A DEDICATED COMMAND, NOT `engine`. The `engine` case below runs `runTrusted` with no
|
|
528
|
+
* principal at all — by design, because those are administrator operations. Routing user
|
|
529
|
+
* preferences through it would take the user id from `data.payload`, i.e. from the
|
|
530
|
+
* browser, and a user could then write somebody else's.
|
|
531
|
+
*
|
|
532
|
+
* ⚠️ AND NOT `runcliscript` EITHER. That resolves an app-authored script by name; a
|
|
533
|
+
* platform operation would have to be seeded into SYS$CLI_SCRIPT per tenant and re-seeded
|
|
534
|
+
* to change.
|
|
535
|
+
*
|
|
536
|
+
* ⚠️ REQUIRED, not observe-shaped. `runcliscript` deliberately lets a null principal
|
|
537
|
+
* through so an un-rebuilt client still works — the script decides. Here there is nothing
|
|
538
|
+
* to decide: without an identity there is no row to read or write, so userPreferences.js
|
|
539
|
+
* throws and the client falls back to its localStorage mirror (§4.2 point 2b).
|
|
540
|
+
*/
|
|
541
|
+
case "userpreferences": {
|
|
542
|
+
const prefPrincipal = principalForCliScript(data.headers, {
|
|
543
|
+
mode: argv["principalMode"] || "observe",
|
|
544
|
+
});
|
|
545
|
+
const prefExec = (sql) => execPostgres(sql, argv["dbindex"]);
|
|
546
|
+
const prefs =
|
|
547
|
+
data.action === "write"
|
|
548
|
+
? await writeUserPreferences({
|
|
549
|
+
exec: prefExec,
|
|
550
|
+
principal: prefPrincipal,
|
|
551
|
+
patch: data.patch,
|
|
552
|
+
})
|
|
553
|
+
: await readUserPreferences({
|
|
554
|
+
exec: prefExec,
|
|
555
|
+
principal: prefPrincipal,
|
|
556
|
+
});
|
|
557
|
+
cb(null, { success: true, data: prefs });
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
/*
|
|
561
|
+
* Doc 6 §7.1.1 step 7 + §13 point 3 — save the COMPANY theme, with its audit trail.
|
|
562
|
+
*
|
|
563
|
+
* ⚠️ THIS EXISTS SO THE AUDIT TRAIL CAN NAME SOMEBODY. The client could write SYS$CONFIG
|
|
564
|
+
* directly through executeBlock, as the App Config screen does — but then the audit row
|
|
565
|
+
* would be authored by the thing being audited, with the user id read out of
|
|
566
|
+
* localStorage. principalForCliScript is what makes "siapa" mean anything.
|
|
567
|
+
*
|
|
568
|
+
* ⚠️ Validation (token names, ranges, WCAG AA) happens inside writeCompanyTheme via the
|
|
569
|
+
* SAME validateThemeSource that gates an application theme at publish — §7.1's one rule
|
|
570
|
+
* for Jalur A and Jalur B.
|
|
571
|
+
*/
|
|
572
|
+
case "companytheme": {
|
|
573
|
+
const themePrincipal = principalForCliScript(data.headers, {
|
|
574
|
+
mode: argv["principalMode"] || "observe",
|
|
575
|
+
});
|
|
576
|
+
const themeExec = (sql) => execPostgres(sql, argv["dbindex"]);
|
|
577
|
+
if (data.action === "write") {
|
|
578
|
+
const saved = await writeCompanyTheme({
|
|
579
|
+
exec: themeExec,
|
|
580
|
+
execStatements: (statements) =>
|
|
581
|
+
execPostgresStatements(statements, argv["dbindex"]),
|
|
582
|
+
principal: themePrincipal,
|
|
583
|
+
theme: data.theme,
|
|
584
|
+
});
|
|
585
|
+
/*
|
|
586
|
+
* ⚠️ A REFUSAL IS NOT AN ERROR. `problems` is what the settings screen shows the
|
|
587
|
+
* admin so they can fix the colour — reporting it as a failed call would lose
|
|
588
|
+
* the explanation and leave them with "save failed".
|
|
589
|
+
*/
|
|
590
|
+
cb(null, {
|
|
591
|
+
success: saved.ok,
|
|
592
|
+
problems: saved.problems,
|
|
593
|
+
data: saved.theme,
|
|
594
|
+
});
|
|
595
|
+
} else {
|
|
596
|
+
cb(null, {
|
|
597
|
+
success: true,
|
|
598
|
+
data: await readCompanyTheme({ exec: themeExec }),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
/*
|
|
604
|
+
* Doc 6 §8.2 — the company logo, under its OWN sys$config key.
|
|
605
|
+
*
|
|
606
|
+
* ⚠️ A SEPARATE COMMAND FROM THE THEME, deliberately. §8.2 point 3 keeps the image out
|
|
607
|
+
* of the theme JSON because the theme is read on every application start and a logo is
|
|
608
|
+
* megabytes; folding it into `companytheme` would undo that at the transport layer even
|
|
609
|
+
* if the storage stayed split.
|
|
610
|
+
*/
|
|
611
|
+
case "companylogo": {
|
|
612
|
+
const logoPrincipal = principalForCliScript(data.headers, {
|
|
613
|
+
mode: argv["principalMode"] || "observe",
|
|
614
|
+
});
|
|
615
|
+
const logoExec = (sql) => execPostgres(sql, argv["dbindex"]);
|
|
616
|
+
const logoStatements = (statements) =>
|
|
617
|
+
execPostgresStatements(statements, argv["dbindex"]);
|
|
618
|
+
|
|
619
|
+
if (data.action === "write") {
|
|
620
|
+
const saved = await writeCompanyLogo({
|
|
621
|
+
exec: logoExec,
|
|
622
|
+
execStatements: logoStatements,
|
|
623
|
+
principal: logoPrincipal,
|
|
624
|
+
dataUrl: data.dataUrl,
|
|
625
|
+
});
|
|
626
|
+
/* ⚠️ A refusal is NOT an error: `problems` is what the settings screen shows the
|
|
627
|
+
admin so they can pick a different file. */
|
|
628
|
+
cb(null, {
|
|
629
|
+
success: saved.ok,
|
|
630
|
+
problems: saved.problems,
|
|
631
|
+
data: saved.logo ?? null,
|
|
632
|
+
});
|
|
633
|
+
} else if (data.action === "clear") {
|
|
634
|
+
const cleared = await clearCompanyLogo({
|
|
635
|
+
exec: logoExec,
|
|
636
|
+
execStatements: logoStatements,
|
|
637
|
+
principal: logoPrincipal,
|
|
638
|
+
});
|
|
639
|
+
cb(null, { success: cleared.ok, problems: cleared.problems, data: null });
|
|
640
|
+
} else {
|
|
641
|
+
cb(null, {
|
|
642
|
+
success: true,
|
|
643
|
+
data: await readCompanyLogo({ exec: logoExec }),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
511
648
|
case "runextcliscript": {
|
|
512
649
|
// External-access op invoker — enforces the capability boundary (dispatch.js),
|
|
513
650
|
// unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 6 §7 point 3 / §8.1 point 4 / AC 3 / AC 28 — WCAG AA contrast, checked at PUBLISH.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ AN ACCESSIBILITY FLOOR, NOT A PREFERENCE. Without it an application can publish a brand colour
|
|
5
|
+
* that renders its users' screens unreadable, into `sys$config`, for every browser of that company.
|
|
6
|
+
* §7 point 3 therefore BLOCKS the publish rather than warning.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️⚠️ THIS IS THE SECOND IMPLEMENTATION OF ONE RULE. The client enforces the same check on save
|
|
9
|
+
* (`client/src/app/theme/contrast.ts`); the CLI cannot import TypeScript, so the maths is written
|
|
10
|
+
* twice. What is NOT written twice is the thing that would actually drift — the preset colour
|
|
11
|
+
* tables — which both sides read from one JSON file. The maths is fixed by WCAG and pinned on both
|
|
12
|
+
* sides by the same exact vectors (black on white is 21:1; #767676 on white passes, #777777 does
|
|
13
|
+
* not), so a divergence fails a test rather than shipping.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* WCAG 2.1: 4.5:1 for normal text, 3:1 for large text and component boundaries. */
|
|
17
|
+
export const AA_NORMAL = 4.5;
|
|
18
|
+
export const AA_LARGE = 3;
|
|
19
|
+
|
|
20
|
+
const parseHex = (hex) => {
|
|
21
|
+
const s = String(hex ?? "").trim().replace(/^#/, "");
|
|
22
|
+
const full =
|
|
23
|
+
s.length === 3
|
|
24
|
+
? s
|
|
25
|
+
.split("")
|
|
26
|
+
.map((c) => c + c)
|
|
27
|
+
.join("")
|
|
28
|
+
: s;
|
|
29
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
|
30
|
+
const n = parseInt(full, 16);
|
|
31
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const toHex = (r, g, b) =>
|
|
35
|
+
"#" +
|
|
36
|
+
[r, g, b]
|
|
37
|
+
.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0"))
|
|
38
|
+
.join("")
|
|
39
|
+
.toUpperCase();
|
|
40
|
+
|
|
41
|
+
/* WCAG relative luminance: channels linearised, then weighted for human sensitivity to green. */
|
|
42
|
+
const luminance = (rgb) => {
|
|
43
|
+
const [r, g, b] = rgb.map((c) => {
|
|
44
|
+
const s = c / 255;
|
|
45
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
46
|
+
});
|
|
47
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WCAG contrast ratio, 1 to 21.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ An unparseable colour scores 0, i.e. FAILING. Treating it as passing would let a typo, or a
|
|
54
|
+
* format the renderer cannot emit, straight through the one check that was meant to stop it.
|
|
55
|
+
*/
|
|
56
|
+
export const contrastRatio = (a, b) => {
|
|
57
|
+
const ca = parseHex(a);
|
|
58
|
+
const cb = parseHex(b);
|
|
59
|
+
if (!ca || !cb) return 0;
|
|
60
|
+
const la = luminance(ca);
|
|
61
|
+
const lb = luminance(cb);
|
|
62
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
63
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const meetsAA = (foreground, background, { large = false } = {}) =>
|
|
67
|
+
contrastRatio(foreground, background) >= (large ? AA_LARGE : AA_NORMAL);
|
|
68
|
+
|
|
69
|
+
/* --- HSL, used only to move lightness while holding the hue ------------- */
|
|
70
|
+
|
|
71
|
+
const toHsl = (rgb) => {
|
|
72
|
+
const [r, g, b] = rgb.map((c) => c / 255);
|
|
73
|
+
const max = Math.max(r, g, b);
|
|
74
|
+
const min = Math.min(r, g, b);
|
|
75
|
+
const l = (max + min) / 2;
|
|
76
|
+
if (max === min) return [0, 0, l];
|
|
77
|
+
const d = max - min;
|
|
78
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
79
|
+
const h =
|
|
80
|
+
max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
81
|
+
return [h / 6, s, l];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const hslToRgb = (h, s, l) => {
|
|
85
|
+
if (s === 0) return [l * 255, l * 255, l * 255];
|
|
86
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
87
|
+
const p = 2 * l - q;
|
|
88
|
+
const channel = (t) => {
|
|
89
|
+
let x = t;
|
|
90
|
+
if (x < 0) x += 1;
|
|
91
|
+
if (x > 1) x -= 1;
|
|
92
|
+
if (x < 1 / 6) return p + (q - p) * 6 * x;
|
|
93
|
+
if (x < 1 / 2) return q;
|
|
94
|
+
if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
|
|
95
|
+
return p;
|
|
96
|
+
};
|
|
97
|
+
return [channel(h + 1 / 3) * 255, channel(h) * 255, channel(h - 1 / 3) * 255];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* AC 28 / §7.1.1 point 6 — the nearest colour of the SAME HUE that passes.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ HUE IS HELD FIXED. A suggestion that changes the brand colour is not one anybody will accept,
|
|
104
|
+
* so only lightness moves, and by the smallest step that passes.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ Returns null rather than a near-miss. A suggestion that still fails would quietly defeat the
|
|
107
|
+
* check it is attached to.
|
|
108
|
+
*/
|
|
109
|
+
export const nearestPassing = (foreground, background, { large = false, maxSteps = 100 } = {}) => {
|
|
110
|
+
const rgb = parseHex(foreground);
|
|
111
|
+
if (!rgb) return null;
|
|
112
|
+
if (meetsAA(foreground, background, { large })) return String(foreground);
|
|
113
|
+
|
|
114
|
+
const [h, s, l] = toHsl(rgb);
|
|
115
|
+
const step = 1 / maxSteps;
|
|
116
|
+
|
|
117
|
+
/* Walk outwards from the original lightness, darker and lighter alternately, so the FIRST hit is
|
|
118
|
+
the smallest visual change in either direction. */
|
|
119
|
+
for (let i = 1; i <= maxSteps; i++) {
|
|
120
|
+
for (const candidateL of [l - i * step, l + i * step]) {
|
|
121
|
+
if (candidateL < 0 || candidateL > 1) continue;
|
|
122
|
+
const [r, g, b] = hslToRgb(h, s, candidateL);
|
|
123
|
+
const hex = toHex(r, g, b);
|
|
124
|
+
if (meetsAA(hex, background, { large })) return hex;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/* --- whole-theme checking ---------------------------------------------- */
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The pairs the app ACTUALLY RENDERS — text on its surface, a label on its brand colour.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ Checking every combination of twelve tokens would report failures for pairs nothing puts
|
|
136
|
+
* together, and a check that cries wolf is one people learn to skip.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ `outlineColor` IS DELIBERATELY ABSENT, and the reason is a gap in Doc 6's vocabulary. WCAG
|
|
139
|
+
* 1.4.11 wants 3:1 for a boundary that IDENTIFIES a control, but Material has two roles here —
|
|
140
|
+
* `outline` (meaningful) and `outline-variant` (decorative divider) — with very different bars, and
|
|
141
|
+
* §5 collapses both into one token. All three presets chose a pale value for it (~1.6:1 on white),
|
|
142
|
+
* i.e. they mean the decorative role; enforcing 3:1 would refuse every shipped preset. The cost is
|
|
143
|
+
* that a boundary which really does identify a control goes unchecked; the fix is a second
|
|
144
|
+
* `outlineVariantColor` token, which is a Doc 6 change, not a code one.
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ Kept identical to the client's `CHECKED_PAIRS` on purpose — one rule for Jalur A and Jalur B
|
|
147
|
+
* (§7.1) means a theme accepted on save must not be refused at publish, or vice versa.
|
|
148
|
+
*/
|
|
149
|
+
export const CHECKED_PAIRS = [
|
|
150
|
+
["onSurfaceColor", "surfaceColor", false],
|
|
151
|
+
["onSurfaceColor", "surfaceContainerColor", false],
|
|
152
|
+
["onPrimaryColor", "primaryColor", false],
|
|
153
|
+
["primaryColor", "surfaceColor", false],
|
|
154
|
+
["errorColor", "surfaceColor", false],
|
|
155
|
+
["warningColor", "surfaceColor", false],
|
|
156
|
+
["successColor", "surfaceColor", false],
|
|
157
|
+
["infoColor", "surfaceColor", false],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const forScheme = (value, scheme) => {
|
|
161
|
+
if (value == null) return null;
|
|
162
|
+
if (typeof value === "string") return value;
|
|
163
|
+
return value[scheme] ?? null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check a theme's rendered colour pairs in BOTH schemes.
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ BOTH, ALWAYS. A theme readable in light and unreadable in dark still makes somebody's screen
|
|
170
|
+
* unusable — and dark is exactly where a hand-picked brand colour tends to fail, because it was
|
|
171
|
+
* chosen against white.
|
|
172
|
+
*
|
|
173
|
+
* @param {{tokens?: object}} theme a theme whose tokens are already RESOLVED against its preset.
|
|
174
|
+
*/
|
|
175
|
+
export const checkThemeContrast = (theme) => {
|
|
176
|
+
const tokens = theme?.tokens;
|
|
177
|
+
const failures = [];
|
|
178
|
+
if (!tokens) return { failures };
|
|
179
|
+
|
|
180
|
+
for (const scheme of ["light", "dark"]) {
|
|
181
|
+
for (const [fgKey, bgKey, large] of CHECKED_PAIRS) {
|
|
182
|
+
const fg = forScheme(tokens[fgKey], scheme);
|
|
183
|
+
const bg = forScheme(tokens[bgKey], scheme);
|
|
184
|
+
if (!fg || !bg) continue;
|
|
185
|
+
|
|
186
|
+
const required = large ? AA_LARGE : AA_NORMAL;
|
|
187
|
+
const ratio = contrastRatio(fg, bg);
|
|
188
|
+
if (ratio < required) {
|
|
189
|
+
failures.push({
|
|
190
|
+
pair: fgKey + " on " + bgKey,
|
|
191
|
+
scheme,
|
|
192
|
+
ratio,
|
|
193
|
+
required,
|
|
194
|
+
suggestion: nearestPassing(fg, bg, { large }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { failures };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render a failure as a line an author can act on.
|
|
204
|
+
*
|
|
205
|
+
* ⚠️ NAMES THE PAIR, THE SCHEME, THE MEASURED RATIO AND A WAY FORWARD. "Contrast too low" tells
|
|
206
|
+
* somebody their publish failed without telling them which of twelve colours to change, in which
|
|
207
|
+
* scheme, or what to change it to — which is how a blocking check becomes a thing people work
|
|
208
|
+
* around instead of fixing.
|
|
209
|
+
*/
|
|
210
|
+
export const describeFailure = (failure) => {
|
|
211
|
+
const head =
|
|
212
|
+
"theme contrast: " +
|
|
213
|
+
failure.pair +
|
|
214
|
+
" in " +
|
|
215
|
+
failure.scheme +
|
|
216
|
+
" scheme is " +
|
|
217
|
+
failure.ratio.toFixed(2) +
|
|
218
|
+
":1, below WCAG AA's " +
|
|
219
|
+
failure.required +
|
|
220
|
+
":1.";
|
|
221
|
+
return failure.suggestion
|
|
222
|
+
? head + " Try " + failure.suggestion + " (same hue, adjusted lightness)."
|
|
223
|
+
: head + " No colour of that hue passes here — pick a different tone.";
|
|
224
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Regenerate `engine/domain/theme-presets.json` from the canonical shared table.
|
|
3
|
+
*
|
|
4
|
+
* node engine/domain/syncThemePresets.js
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ WHY A MIRROR AT ALL. `shared/domain/theme-presets.json` is the one table both codebases read —
|
|
7
|
+
* the client imports it directly, and Doc 6 §7.1 asks for exactly that single registry. But
|
|
8
|
+
* `biz-a-cli` is a PUBLISHED npm package, and npm's `files[]` cannot reach a parent directory, so a
|
|
9
|
+
* published install has no `../shared`. The mirror is what ships.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ NEVER HAND-EDIT THE MIRROR. `tests/theme-contrast.test.js` compares the two byte for byte and
|
|
12
|
+
* fails the moment they differ — a silently stale mirror would mean the publish-time contrast check
|
|
13
|
+
* (§8.1 point 4) judged themes against colours the app no longer uses.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const shared = path.join(here, "..", "..", "..", "shared", "domain", "theme-presets.json");
|
|
21
|
+
const mirror = path.join(here, "theme-presets.json");
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(shared)) {
|
|
24
|
+
console.error(
|
|
25
|
+
"cannot sync: the canonical table is missing at " +
|
|
26
|
+
shared +
|
|
27
|
+
"\nRun this from a full checkout, not a published install.",
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fs.copyFileSync(shared, mirror);
|
|
33
|
+
console.log("theme presets synced: " + shared + " -> " + mirror);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
{
|
|
2
|
+
"presets": [
|
|
3
|
+
{
|
|
4
|
+
"preset": "klasik",
|
|
5
|
+
"version": 1,
|
|
6
|
+
"name": "Klasik",
|
|
7
|
+
"colorScheme": "light",
|
|
8
|
+
"density": 0,
|
|
9
|
+
"tokens": {
|
|
10
|
+
"primaryColor": {
|
|
11
|
+
"light": "#1B5FCB",
|
|
12
|
+
"dark": "#A9C7FF"
|
|
13
|
+
},
|
|
14
|
+
"onPrimaryColor": {
|
|
15
|
+
"light": "#FFFFFF",
|
|
16
|
+
"dark": "#08306B"
|
|
17
|
+
},
|
|
18
|
+
"secondaryColor": {
|
|
19
|
+
"light": "#4A6180",
|
|
20
|
+
"dark": "#B9C6DC"
|
|
21
|
+
},
|
|
22
|
+
"tertiaryColor": {
|
|
23
|
+
"light": "#0E7C86",
|
|
24
|
+
"dark": "#6FD3DC"
|
|
25
|
+
},
|
|
26
|
+
"surfaceColor": {
|
|
27
|
+
"light": "#FFFFFF",
|
|
28
|
+
"dark": "#111419"
|
|
29
|
+
},
|
|
30
|
+
"surfaceContainerColor": {
|
|
31
|
+
"light": "#F1F4F9",
|
|
32
|
+
"dark": "#1A1F27"
|
|
33
|
+
},
|
|
34
|
+
"onSurfaceColor": {
|
|
35
|
+
"light": "#1A1D23",
|
|
36
|
+
"dark": "#E4E8EF"
|
|
37
|
+
},
|
|
38
|
+
"outlineColor": {
|
|
39
|
+
"light": "#C5CBD6",
|
|
40
|
+
"dark": "#454C59"
|
|
41
|
+
},
|
|
42
|
+
"errorColor": {
|
|
43
|
+
"light": "#C0283B",
|
|
44
|
+
"dark": "#FF9AA4"
|
|
45
|
+
},
|
|
46
|
+
"warningColor": {
|
|
47
|
+
"light": "#B25E00",
|
|
48
|
+
"dark": "#F5B067"
|
|
49
|
+
},
|
|
50
|
+
"successColor": {
|
|
51
|
+
"light": "#16794A",
|
|
52
|
+
"dark": "#7CD8A6"
|
|
53
|
+
},
|
|
54
|
+
"infoColor": {
|
|
55
|
+
"light": "#0E6FBF",
|
|
56
|
+
"dark": "#8FC6F5"
|
|
57
|
+
},
|
|
58
|
+
"fontFamily": "Inter, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
|
59
|
+
"fontScale": "normal",
|
|
60
|
+
"borderRadius": "8px",
|
|
61
|
+
"spacingScale": "1",
|
|
62
|
+
"elevation": "raised"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"preset": "teduh",
|
|
67
|
+
"version": 1,
|
|
68
|
+
"name": "Teduh",
|
|
69
|
+
"colorScheme": "light",
|
|
70
|
+
"density": 0,
|
|
71
|
+
"tokens": {
|
|
72
|
+
"primaryColor": {
|
|
73
|
+
"light": "#1F6F55",
|
|
74
|
+
"dark": "#86D6B4"
|
|
75
|
+
},
|
|
76
|
+
"onPrimaryColor": {
|
|
77
|
+
"light": "#FFFFFF",
|
|
78
|
+
"dark": "#00382A"
|
|
79
|
+
},
|
|
80
|
+
"secondaryColor": {
|
|
81
|
+
"light": "#5B6B62",
|
|
82
|
+
"dark": "#BCCCC2"
|
|
83
|
+
},
|
|
84
|
+
"tertiaryColor": {
|
|
85
|
+
"light": "#9C5F33",
|
|
86
|
+
"dark": "#F0B489"
|
|
87
|
+
},
|
|
88
|
+
"surfaceColor": {
|
|
89
|
+
"light": "#FCFBF8",
|
|
90
|
+
"dark": "#131512"
|
|
91
|
+
},
|
|
92
|
+
"surfaceContainerColor": {
|
|
93
|
+
"light": "#F1EFE9",
|
|
94
|
+
"dark": "#1C1F1C"
|
|
95
|
+
},
|
|
96
|
+
"onSurfaceColor": {
|
|
97
|
+
"light": "#1D211E",
|
|
98
|
+
"dark": "#E3E6E0"
|
|
99
|
+
},
|
|
100
|
+
"outlineColor": {
|
|
101
|
+
"light": "#C9C7BD",
|
|
102
|
+
"dark": "#474B47"
|
|
103
|
+
},
|
|
104
|
+
"errorColor": {
|
|
105
|
+
"light": "#B3261E",
|
|
106
|
+
"dark": "#FFA39B"
|
|
107
|
+
},
|
|
108
|
+
"warningColor": {
|
|
109
|
+
"light": "#A16700",
|
|
110
|
+
"dark": "#EDB759"
|
|
111
|
+
},
|
|
112
|
+
"successColor": {
|
|
113
|
+
"light": "#2E7D4F",
|
|
114
|
+
"dark": "#8AD3A5"
|
|
115
|
+
},
|
|
116
|
+
"infoColor": {
|
|
117
|
+
"light": "#2C6E9B",
|
|
118
|
+
"dark": "#96C6E8"
|
|
119
|
+
},
|
|
120
|
+
"fontFamily": "'Plus Jakarta Sans', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
|
121
|
+
"fontScale": "normal",
|
|
122
|
+
"borderRadius": "12px",
|
|
123
|
+
"spacingScale": "1",
|
|
124
|
+
"elevation": "flat"
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"preset": "tegas",
|
|
129
|
+
"version": 1,
|
|
130
|
+
"name": "Tegas",
|
|
131
|
+
"colorScheme": "light",
|
|
132
|
+
"density": -1,
|
|
133
|
+
"tokens": {
|
|
134
|
+
"primaryColor": {
|
|
135
|
+
"light": "#B45309",
|
|
136
|
+
"dark": "#FFB454"
|
|
137
|
+
},
|
|
138
|
+
"onPrimaryColor": {
|
|
139
|
+
"light": "#FFFFFF",
|
|
140
|
+
"dark": "#452300"
|
|
141
|
+
},
|
|
142
|
+
"secondaryColor": {
|
|
143
|
+
"light": "#3F4550",
|
|
144
|
+
"dark": "#C3CAD6"
|
|
145
|
+
},
|
|
146
|
+
"tertiaryColor": {
|
|
147
|
+
"light": "#2F6F8F",
|
|
148
|
+
"dark": "#8FC9E5"
|
|
149
|
+
},
|
|
150
|
+
"surfaceColor": {
|
|
151
|
+
"light": "#FFFFFF",
|
|
152
|
+
"dark": "#0F1114"
|
|
153
|
+
},
|
|
154
|
+
"surfaceContainerColor": {
|
|
155
|
+
"light": "#F2F3F5",
|
|
156
|
+
"dark": "#181B20"
|
|
157
|
+
},
|
|
158
|
+
"onSurfaceColor": {
|
|
159
|
+
"light": "#14171C",
|
|
160
|
+
"dark": "#E8EAEE"
|
|
161
|
+
},
|
|
162
|
+
"outlineColor": {
|
|
163
|
+
"light": "#B9BEC7",
|
|
164
|
+
"dark": "#3C424B"
|
|
165
|
+
},
|
|
166
|
+
"errorColor": {
|
|
167
|
+
"light": "#C0283B",
|
|
168
|
+
"dark": "#FF9AA4"
|
|
169
|
+
},
|
|
170
|
+
"warningColor": {
|
|
171
|
+
"light": "#B25E00",
|
|
172
|
+
"dark": "#F5B067"
|
|
173
|
+
},
|
|
174
|
+
"successColor": {
|
|
175
|
+
"light": "#16794A",
|
|
176
|
+
"dark": "#7CD8A6"
|
|
177
|
+
},
|
|
178
|
+
"infoColor": {
|
|
179
|
+
"light": "#0E6FBF",
|
|
180
|
+
"dark": "#8FC6F5"
|
|
181
|
+
},
|
|
182
|
+
"fontFamily": "Inter, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
|
183
|
+
"fontScale": "normal",
|
|
184
|
+
"borderRadius": "6px",
|
|
185
|
+
"spacingScale": "1",
|
|
186
|
+
"elevation": "flat"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|