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 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
+ }