@spacefast/common 0.0.10 → 0.0.12
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/dist/agents/connect-targets.d.ts +4 -4
- package/dist/agents/connect-targets.js +3 -3
- package/dist/agents/private-key-oauth.d.ts +37 -0
- package/dist/agents/private-key-oauth.js +158 -0
- package/dist/config/domains.js +1 -1
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/postgres-budget.d.ts +126 -0
- package/dist/config/postgres-budget.js +135 -0
- package/dist/config/postgres-budget.json +50 -0
- package/dist/contracts/access.d.ts +42 -24
- package/dist/contracts/access.js +73 -34
- package/dist/contracts/activity.d.ts +1 -1
- package/dist/contracts/activity.js +5 -5
- package/dist/contracts/api-keys.d.ts +53 -45
- package/dist/contracts/api-keys.js +1 -0
- package/dist/contracts/archives.d.ts +8 -8
- package/dist/contracts/builds.d.ts +8 -8
- package/dist/contracts/builds.js +28 -48
- package/dist/contracts/channels.js +1 -2
- package/dist/contracts/comments.d.ts +55 -19
- package/dist/contracts/comments.js +56 -17
- package/dist/contracts/common.d.ts +4 -3
- package/dist/contracts/common.js +2 -0
- package/dist/contracts/deployments.d.ts +3 -3
- package/dist/contracts/device-auth.d.ts +14 -12
- package/dist/contracts/docs.d.ts +5 -5
- package/dist/contracts/domains.d.ts +261 -1330
- package/dist/contracts/domains.js +39 -301
- package/dist/contracts/email-preferences.d.ts +50 -0
- package/dist/contracts/email-preferences.js +44 -0
- package/dist/contracts/enums.d.ts +1 -1
- package/dist/contracts/error-code-meta.d.ts +84 -15
- package/dist/contracts/error-code-meta.js +28 -4
- package/dist/contracts/error-codes.d.ts +1 -1
- package/dist/contracts/error-codes.js +24 -6
- package/dist/contracts/events.d.ts +13 -13
- package/dist/contracts/feature-lifecycle.d.ts +644 -2
- package/dist/contracts/feature-lifecycle.js +685 -2
- package/dist/contracts/features.d.ts +236 -24
- package/dist/contracts/features.js +3 -272
- package/dist/contracts/ids.d.ts +1 -2
- package/dist/contracts/ids.js +1 -2
- package/dist/contracts/internal.d.ts +3 -3
- package/dist/contracts/mcp.d.ts +6 -6
- package/dist/contracts/me.js +1 -1
- package/dist/contracts/mounts.d.ts +142 -0
- package/dist/contracts/mounts.js +56 -0
- package/dist/contracts/oauth-resources.d.ts +5 -5
- package/dist/contracts/oauth-resources.js +1 -0
- package/dist/contracts/operations.d.ts +2 -2
- package/dist/contracts/pages.d.ts +2 -2
- package/dist/contracts/pages.js +8 -3
- package/dist/contracts/plan-policy.d.ts +1 -1
- package/dist/contracts/platform.d.ts +3 -3
- package/dist/contracts/resources.d.ts +12 -20
- package/dist/contracts/resources.js +13 -37
- package/dist/contracts/runtime-api.d.ts +330 -16
- package/dist/contracts/runtime-api.js +58 -14
- package/dist/contracts/sf-config-v1.js +1 -1
- package/dist/contracts/sites.d.ts +12 -12
- package/dist/contracts/sites.js +18 -11
- package/dist/contracts/space-config.d.ts +2 -3
- package/dist/contracts/space-config.js +3 -1
- package/dist/contracts/spaces.d.ts +143 -102
- package/dist/contracts/spaces.js +109 -43
- package/dist/contracts/superadmin-emails.d.ts +25 -1
- package/dist/contracts/superadmin-emails.js +10 -15
- package/dist/contracts/superadmin-runtime.d.ts +2 -2
- package/dist/contracts/superadmin-spaces.d.ts +6 -14
- package/dist/contracts/tags.d.ts +43 -13
- package/dist/contracts/tags.js +195 -11
- package/dist/contracts/transfers.d.ts +1 -1
- package/dist/contracts/variables.d.ts +4 -4
- package/dist/contracts/variables.js +4 -3
- package/dist/contracts/webhooks.d.ts +2 -2
- package/dist/contracts/zero.d.ts +329 -7
- package/dist/contracts/zero.js +121 -2
- package/dist/dashboard-paths/index.d.ts +1 -1
- package/dist/docs/agent-setup.d.ts +4 -2
- package/dist/docs/agent-setup.js +15 -7
- package/dist/docs/agent-solutions.js +1 -1
- package/dist/docs/catalog.d.ts +8 -10
- package/dist/docs/catalog.js +8 -10
- package/dist/docs/error-docs.js +86 -18
- package/dist/utils/access-grant.d.ts +14 -0
- package/dist/utils/access-grant.js +88 -0
- package/dist/utils/access-match.d.ts +7 -0
- package/dist/utils/access-match.fixtures.json +513 -0
- package/dist/utils/access-match.js +32 -16
- package/dist/utils/access-verdict.fixtures.json +421 -0
- package/dist/utils/credential-policy.d.ts +1 -1
- package/dist/utils/dns-instructions.d.ts +29 -0
- package/dist/utils/dns-instructions.js +78 -0
- package/dist/utils/domain-diagnostics.d.ts +29 -0
- package/dist/utils/domain-diagnostics.js +63 -0
- package/dist/utils/egress-policy.fixtures.json +51 -0
- package/dist/utils/id-hints.d.ts +1 -0
- package/dist/utils/id-hints.js +55 -13
- package/dist/utils/page-colors.d.ts +50 -0
- package/dist/utils/page-colors.js +167 -0
- package/dist/utils/page-fonts.d.ts +2 -0
- package/dist/utils/page-fonts.js +4 -0
- package/dist/utils/pages.d.ts +8 -1
- package/dist/utils/pages.js +123 -37
- package/dist/utils/production-runtime.d.ts +18 -0
- package/dist/utils/production-runtime.js +28 -0
- package/dist/utils/query-keys.d.ts +6 -7
- package/dist/utils/query-keys.js +11 -12
- package/dist/utils/sf-config-v1.fixtures.json +9 -0
- package/dist/utils/sf-config-v1.js +28 -19
- package/dist/utils/source-archive-policy.d.ts +2 -0
- package/dist/utils/source-archive-policy.js +2 -0
- package/dist/utils/static-runtime-policy.fixtures.json +57 -0
- package/dist/utils/static-runtime-policy.js +1 -1
- package/package.json +5 -4
- package/dist/contracts/annotations.d.ts +0 -327
- package/dist/contracts/annotations.js +0 -235
- package/dist/contracts/generated-feature-launch-entries.d.ts +0 -2
- package/dist/contracts/generated-feature-launch-entries.js +0 -518
- package/dist/contracts/publishes.d.ts +0 -135
- package/dist/contracts/publishes.js +0 -162
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"comment": "Shared egress-policy corpus (spec: one proxy-egress-safety invariant). Exercised by BOTH enforcers: apps/control-plane/src/http/egress-guard.ts (control-plane SSRF guard) and runtime/engine/shared/egress.php (runtime proxy egress). Every case must produce the same verdict in TypeScript and PHP; parity is asserted by apps/control-plane/src/runtime/php-policy-parity.test.ts and src/http/egress-guard.test.ts.",
|
|
3
|
+
"ips": [
|
|
4
|
+
{ "ip": "127.0.0.1", "public": false, "reason": "loopback" },
|
|
5
|
+
{ "ip": "127.8.9.10", "public": false, "reason": "loopback /8" },
|
|
6
|
+
{ "ip": "0.0.0.0", "public": false, "reason": "this-network" },
|
|
7
|
+
{ "ip": "10.0.0.1", "public": false, "reason": "RFC1918" },
|
|
8
|
+
{ "ip": "172.16.0.1", "public": false, "reason": "RFC1918" },
|
|
9
|
+
{ "ip": "172.31.255.255", "public": false, "reason": "RFC1918 upper bound" },
|
|
10
|
+
{ "ip": "172.32.0.1", "public": true, "reason": "outside 172.16/12" },
|
|
11
|
+
{ "ip": "192.168.1.1", "public": false, "reason": "RFC1918" },
|
|
12
|
+
{ "ip": "100.64.0.1", "public": false, "reason": "carrier-grade NAT" },
|
|
13
|
+
{ "ip": "169.254.169.254", "public": false, "reason": "cloud metadata" },
|
|
14
|
+
{ "ip": "169.254.0.1", "public": false, "reason": "link-local" },
|
|
15
|
+
{ "ip": "192.0.0.1", "public": false, "reason": "special-purpose" },
|
|
16
|
+
{ "ip": "192.0.2.1", "public": false, "reason": "TEST-NET-1" },
|
|
17
|
+
{ "ip": "198.18.0.1", "public": false, "reason": "benchmarking" },
|
|
18
|
+
{ "ip": "198.51.100.7", "public": false, "reason": "TEST-NET-2" },
|
|
19
|
+
{ "ip": "203.0.113.9", "public": false, "reason": "TEST-NET-3" },
|
|
20
|
+
{ "ip": "224.0.0.1", "public": false, "reason": "multicast" },
|
|
21
|
+
{ "ip": "255.255.255.255", "public": false, "reason": "broadcast" },
|
|
22
|
+
{ "ip": "8.8.8.8", "public": true, "reason": "public" },
|
|
23
|
+
{ "ip": "1.1.1.1", "public": true, "reason": "public" },
|
|
24
|
+
{ "ip": "93.184.216.34", "public": true, "reason": "public" },
|
|
25
|
+
{ "ip": "::1", "public": false, "reason": "loopback" },
|
|
26
|
+
{ "ip": "::", "public": false, "reason": "unspecified" },
|
|
27
|
+
{ "ip": "fe80::1", "public": false, "reason": "link-local" },
|
|
28
|
+
{ "ip": "fc00::1", "public": false, "reason": "unique-local" },
|
|
29
|
+
{ "ip": "fd00:ec2::254", "public": false, "reason": "ULA cloud metadata" },
|
|
30
|
+
{ "ip": "ff02::1", "public": false, "reason": "multicast" },
|
|
31
|
+
{ "ip": "2001:db8::1", "public": false, "reason": "documentation" },
|
|
32
|
+
{ "ip": "100::1", "public": false, "reason": "discard-only" },
|
|
33
|
+
{ "ip": "64:ff9b::808:808", "public": false, "reason": "NAT64 denied outright" },
|
|
34
|
+
{ "ip": "::ffff:10.0.0.1", "public": false, "reason": "IPv4-mapped private" },
|
|
35
|
+
{ "ip": "::ffff:a00:1", "public": false, "reason": "IPv4-mapped private (hex form)" },
|
|
36
|
+
{
|
|
37
|
+
"ip": "::ffff:8.8.8.8",
|
|
38
|
+
"public": true,
|
|
39
|
+
"reason": "IPv4-mapped public defers to inner policy"
|
|
40
|
+
},
|
|
41
|
+
{ "ip": "2606:4700:4700::1111", "public": true, "reason": "public" }
|
|
42
|
+
],
|
|
43
|
+
"hosts": [
|
|
44
|
+
{ "host": "localhost", "allowed": false },
|
|
45
|
+
{ "host": "sub.localhost", "allowed": false },
|
|
46
|
+
{ "host": "169.254.169.254", "allowed": false },
|
|
47
|
+
{ "host": "view.fast", "allowed": false },
|
|
48
|
+
{ "host": "wpc-manage-site-123.view.fast", "allowed": false },
|
|
49
|
+
{ "host": "example.com", "allowed": true }
|
|
50
|
+
]
|
|
51
|
+
}
|
package/dist/utils/id-hints.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const HINT_SCAN_CAP = 200;
|
|
2
2
|
export declare const HINT_MAX_CANDIDATES = 5;
|
|
3
|
+
export declare const HINT_MAX_KEY_LENGTH = 256;
|
|
3
4
|
export declare function levenshtein(a: string, b: string): number;
|
|
4
5
|
export declare function refCloseness(needle: string, candidate: string): number;
|
|
5
6
|
export declare function rankCandidates<T>(ref: string, rows: T[], keysOf: (row: T) => Array<string | null | undefined>, options?: {
|
package/dist/utils/id-hints.js
CHANGED
|
@@ -9,19 +9,44 @@
|
|
|
9
9
|
// returns only the recovery hint (no candidate ids).
|
|
10
10
|
export const HINT_SCAN_CAP = 200;
|
|
11
11
|
export const HINT_MAX_CANDIDATES = 5;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
// All supported hint keys (ids, slugs, hostnames, and numeric version refs) fit
|
|
13
|
+
// within this ceiling. Rejecting larger values keeps best-effort 404 recovery
|
|
14
|
+
// from turning attacker-controlled refs or stored labels into quadratic work.
|
|
15
|
+
export const HINT_MAX_KEY_LENGTH = 256;
|
|
16
|
+
function boundedLevenshtein(a, b, maxDistance) {
|
|
17
|
+
if (Math.abs(a.length - b.length) > maxDistance)
|
|
18
|
+
return maxDistance + 1;
|
|
19
|
+
// Keep the rolling rows on the shorter input. The band avoids evaluating
|
|
20
|
+
// cells that cannot contribute a result within maxDistance.
|
|
21
|
+
if (b.length > a.length)
|
|
22
|
+
[a, b] = [b, a];
|
|
23
|
+
let previous = new Uint16Array(b.length + 1);
|
|
24
|
+
let current = new Uint16Array(b.length + 1);
|
|
25
|
+
for (let j = 0; j <= b.length; j++)
|
|
26
|
+
previous[j] = j;
|
|
27
|
+
const outsideBand = maxDistance + 1;
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
current.fill(outsideBand);
|
|
30
|
+
current[0] = i <= maxDistance ? i : outsideBand;
|
|
31
|
+
const start = Math.max(1, i - maxDistance);
|
|
32
|
+
const end = Math.min(b.length, i + maxDistance);
|
|
33
|
+
let rowMinimum = current[0] ?? outsideBand;
|
|
34
|
+
for (let j = start; j <= end; j++) {
|
|
19
35
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
20
|
-
current[j] = Math.min((current[j - 1] ??
|
|
36
|
+
current[j] = Math.min((current[j - 1] ?? outsideBand) + 1, (previous[j] ?? outsideBand) + 1, (previous[j - 1] ?? outsideBand) + cost);
|
|
37
|
+
rowMinimum = Math.min(rowMinimum, current[j] ?? outsideBand);
|
|
21
38
|
}
|
|
22
|
-
|
|
39
|
+
if (rowMinimum > maxDistance)
|
|
40
|
+
return outsideBand;
|
|
41
|
+
[previous, current] = [current, previous];
|
|
42
|
+
}
|
|
43
|
+
return previous[b.length] ?? outsideBand;
|
|
44
|
+
}
|
|
45
|
+
export function levenshtein(a, b) {
|
|
46
|
+
if (a.length > HINT_MAX_KEY_LENGTH || b.length > HINT_MAX_KEY_LENGTH) {
|
|
47
|
+
return Number.POSITIVE_INFINITY;
|
|
23
48
|
}
|
|
24
|
-
return
|
|
49
|
+
return boundedLevenshtein(a, b, Math.max(a.length, b.length));
|
|
25
50
|
}
|
|
26
51
|
// Returns a small finite score the closer `candidate` is to `needle`, or
|
|
27
52
|
// +Infinity when they are not genuinely close. Exact match = 0; substring
|
|
@@ -29,14 +54,17 @@ export function levenshtein(a, b) {
|
|
|
29
54
|
export function refCloseness(needle, candidate) {
|
|
30
55
|
if (!candidate)
|
|
31
56
|
return Number.POSITIVE_INFINITY;
|
|
57
|
+
if (needle.length > HINT_MAX_KEY_LENGTH || candidate.length > HINT_MAX_KEY_LENGTH) {
|
|
58
|
+
return Number.POSITIVE_INFINITY;
|
|
59
|
+
}
|
|
32
60
|
if (candidate === needle)
|
|
33
61
|
return 0;
|
|
34
62
|
if (candidate.includes(needle) || needle.includes(candidate))
|
|
35
63
|
return 1;
|
|
36
|
-
const distance = levenshtein(needle, candidate);
|
|
37
64
|
// Only surface genuinely close typos: bound the allowed distance by the shorter
|
|
38
65
|
// string so short refs don't match everything.
|
|
39
66
|
const tolerance = Math.max(2, Math.floor(Math.min(needle.length, candidate.length) / 3));
|
|
67
|
+
const distance = boundedLevenshtein(needle, candidate, tolerance);
|
|
40
68
|
return distance <= tolerance ? 1 + distance : Number.POSITIVE_INFINITY;
|
|
41
69
|
}
|
|
42
70
|
// Rank `rows` by how close any of their keys are to `ref`, returning at most
|
|
@@ -44,14 +72,28 @@ export function refCloseness(needle, candidate) {
|
|
|
44
72
|
// only genuine near-matches surface. Ties break by the optional comparator
|
|
45
73
|
// (e.g. recency); keys are matched case-insensitively.
|
|
46
74
|
export function rankCandidates(ref, rows, keysOf, options) {
|
|
75
|
+
if (rows.length > HINT_SCAN_CAP || ref.length > HINT_MAX_KEY_LENGTH)
|
|
76
|
+
return [];
|
|
47
77
|
const needle = ref.toLowerCase();
|
|
78
|
+
// Unicode case conversion can expand a string (for example, U+0130). Keep
|
|
79
|
+
// that normalized representation under the same work ceiling too.
|
|
80
|
+
if (needle.length > HINT_MAX_KEY_LENGTH)
|
|
81
|
+
return [];
|
|
48
82
|
const max = options?.max ?? HINT_MAX_CANDIDATES;
|
|
49
83
|
return rows
|
|
50
84
|
.map((row) => {
|
|
51
|
-
|
|
85
|
+
let score = Number.POSITIVE_INFINITY;
|
|
86
|
+
for (const key of keysOf(row)) {
|
|
87
|
+
if (!key || key.length > HINT_MAX_KEY_LENGTH)
|
|
88
|
+
continue;
|
|
89
|
+
const candidate = key.toLowerCase();
|
|
90
|
+
if (candidate.length > HINT_MAX_KEY_LENGTH)
|
|
91
|
+
continue;
|
|
92
|
+
score = Math.min(score, refCloseness(needle, candidate));
|
|
93
|
+
}
|
|
52
94
|
return {
|
|
53
95
|
row,
|
|
54
|
-
score
|
|
96
|
+
score,
|
|
55
97
|
};
|
|
56
98
|
})
|
|
57
99
|
.filter((entry) => entry.score < Number.POSITIVE_INFINITY)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SpacePageTheme } from "../contracts/space-config.js";
|
|
2
|
+
/** Default page palette (mirrored into the shared template CSS). */
|
|
3
|
+
export declare const PAGE_COLOR_DEFAULTS: {
|
|
4
|
+
readonly accent: "#ff4217";
|
|
5
|
+
readonly bg: "#f9f8f4";
|
|
6
|
+
readonly fg: "#1a1913";
|
|
7
|
+
readonly muted: "#6f6b62";
|
|
8
|
+
readonly card: "#f9f8f4";
|
|
9
|
+
readonly border: "#ddd9cf";
|
|
10
|
+
readonly darkBg: "#141313";
|
|
11
|
+
readonly darkFg: "#e9e7e2";
|
|
12
|
+
readonly darkMuted: "#8f8b84";
|
|
13
|
+
readonly darkCard: "#141313";
|
|
14
|
+
readonly darkBorder: "#2b2a27";
|
|
15
|
+
};
|
|
16
|
+
export type PageSchemeColors = {
|
|
17
|
+
bg: string;
|
|
18
|
+
fg: string;
|
|
19
|
+
muted: string;
|
|
20
|
+
card: string;
|
|
21
|
+
border: string;
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
export type DerivedPageColors = {
|
|
25
|
+
/** `adaptive` keeps the built-in light/dark schemes; otherwise pinned. */
|
|
26
|
+
scheme: "adaptive" | "light" | "dark";
|
|
27
|
+
/** The accent exactly as pushed (fills keep the brand color verbatim). */
|
|
28
|
+
accent: string;
|
|
29
|
+
/** Label color on accent fills. */
|
|
30
|
+
onAccent: string;
|
|
31
|
+
/** Accent-derived link color, readable on the light (or pinned) surface. */
|
|
32
|
+
link: string;
|
|
33
|
+
/** Adaptive only: link color readable on the built-in dark surface. */
|
|
34
|
+
linkDark?: string;
|
|
35
|
+
/** Full derived scheme when a background pins the page. */
|
|
36
|
+
pinned?: PageSchemeColors;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Return `color` unchanged when it already meets `target` on `surface`;
|
|
40
|
+
* otherwise move its OKLCH lightness (hue/chroma preserved) just far enough
|
|
41
|
+
* to meet it. Falls back to plain black/white when even full lightness range
|
|
42
|
+
* cannot reach the target (e.g. a mid-gray surface).
|
|
43
|
+
*/
|
|
44
|
+
export declare function readableOn(color: string, surface: string, target: number): string;
|
|
45
|
+
/** Button-label color for an accent fill: white when it can carry, else ink. */
|
|
46
|
+
export declare function onAccentColor(accent: string): string;
|
|
47
|
+
/** The one entry point: raw theme in, everything the templates paint with out. */
|
|
48
|
+
export declare function derivePageColors(theme: Pick<SpacePageTheme, "accent" | "background">): DerivedPageColors;
|
|
49
|
+
/** Hex form of any schema-accepted color, for color-picker inputs. */
|
|
50
|
+
export declare function pageColorToHex(value: string): string | null;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Readability engine for Spacefast-authored pages.
|
|
2
|
+
//
|
|
3
|
+
// Theme colors are intent, not CSS: users and agents push any accent or
|
|
4
|
+
// background (dashboard, spacefast.jsonc, API) and the platform guarantees
|
|
5
|
+
// every rendered page stays readable. This module derives the full set of
|
|
6
|
+
// colors the templates actually paint with:
|
|
7
|
+
//
|
|
8
|
+
// - `onAccent` — button-label color on accent fills (black or white).
|
|
9
|
+
// - `link` — the accent, lightness-adjusted until it meets WCAG AA
|
|
10
|
+
// against the surface it sits on. Hue and chroma survive,
|
|
11
|
+
// so it still reads as the brand color.
|
|
12
|
+
// - `pinned` — when a background is set, a complete coherent scheme
|
|
13
|
+
// (fg/muted/surface/border) derived from it and pinned for
|
|
14
|
+
// every visitor. A chosen background IS the page; the
|
|
15
|
+
// visitor's OS dark-mode setting must not repaint half the
|
|
16
|
+
// variables into an unreadable hybrid.
|
|
17
|
+
//
|
|
18
|
+
// Everything is pure and deterministic: publish finalize, dashboard previews,
|
|
19
|
+
// and tests all call the same functions and get the same pixels.
|
|
20
|
+
import { formatHex, oklch, parse, wcagContrast } from "culori";
|
|
21
|
+
/** Default page palette (mirrored into the shared template CSS). */
|
|
22
|
+
export const PAGE_COLOR_DEFAULTS = {
|
|
23
|
+
accent: "#ff4217",
|
|
24
|
+
bg: "#f9f8f4",
|
|
25
|
+
fg: "#1a1913",
|
|
26
|
+
muted: "#6f6b62",
|
|
27
|
+
card: "#f9f8f4",
|
|
28
|
+
border: "#ddd9cf",
|
|
29
|
+
darkBg: "#141313",
|
|
30
|
+
darkFg: "#e9e7e2",
|
|
31
|
+
darkMuted: "#8f8b84",
|
|
32
|
+
darkCard: "#141313",
|
|
33
|
+
darkBorder: "#2b2a27",
|
|
34
|
+
};
|
|
35
|
+
/** WCAG AA for body-size text. */
|
|
36
|
+
const TEXT_CONTRAST = 4.5;
|
|
37
|
+
/** Body-size button labels on accent fills. */
|
|
38
|
+
const UI_CONTRAST = 4.5;
|
|
39
|
+
/** Essential control boundaries against their painted surface. */
|
|
40
|
+
const CONTROL_CONTRAST = 3;
|
|
41
|
+
/** Primary foreground meets AA even on mid-tone custom backgrounds. */
|
|
42
|
+
const FG_CONTRAST = 4.5;
|
|
43
|
+
function safeParse(value) {
|
|
44
|
+
if (!value)
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return parse(value.trim());
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function contrast(a, b) {
|
|
54
|
+
return wcagContrast(a, b);
|
|
55
|
+
}
|
|
56
|
+
function isLightSurface(surface) {
|
|
57
|
+
return contrast("#000000", surface) >= contrast("#ffffff", surface);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Return `color` unchanged when it already meets `target` on `surface`;
|
|
61
|
+
* otherwise move its OKLCH lightness (hue/chroma preserved) just far enough
|
|
62
|
+
* to meet it. Falls back to plain black/white when even full lightness range
|
|
63
|
+
* cannot reach the target (e.g. a mid-gray surface).
|
|
64
|
+
*/
|
|
65
|
+
export function readableOn(color, surface, target) {
|
|
66
|
+
if (contrast(color, surface) >= target)
|
|
67
|
+
return color;
|
|
68
|
+
const parsed = safeParse(color);
|
|
69
|
+
const converted = parsed ? oklch(parsed) : undefined;
|
|
70
|
+
if (!converted)
|
|
71
|
+
return isLightSurface(surface) ? "#000000" : "#ffffff";
|
|
72
|
+
const base = { mode: "oklch", l: converted.l, c: converted.c, h: converted.h ?? 0 };
|
|
73
|
+
const darken = isLightSurface(surface);
|
|
74
|
+
const limit = darken ? 0 : 1;
|
|
75
|
+
const atLimit = formatHex({ ...base, l: limit });
|
|
76
|
+
if (contrast(atLimit, surface) < target) {
|
|
77
|
+
return isLightSurface(surface) ? "#000000" : "#ffffff";
|
|
78
|
+
}
|
|
79
|
+
// Binary search for the lightness closest to the original that still meets
|
|
80
|
+
// the target; contrast is monotonic in lightness on a fixed surface side.
|
|
81
|
+
let searchLimit = limit;
|
|
82
|
+
let searchStart = base.l;
|
|
83
|
+
for (let i = 0; i < 24; i += 1) {
|
|
84
|
+
const mid = (searchStart + searchLimit) / 2;
|
|
85
|
+
if (contrast(formatHex({ ...base, l: mid }), surface) >= target) {
|
|
86
|
+
searchLimit = mid;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
searchStart = mid;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return formatHex({ ...base, l: searchLimit });
|
|
93
|
+
}
|
|
94
|
+
/** Button-label color for an accent fill: white when it can carry, else ink. */
|
|
95
|
+
export function onAccentColor(accent) {
|
|
96
|
+
return contrast("#ffffff", accent) >= UI_CONTRAST ? "#ffffff" : "#000000";
|
|
97
|
+
}
|
|
98
|
+
function clamp(value, min, max) {
|
|
99
|
+
return Math.min(max, Math.max(min, value));
|
|
100
|
+
}
|
|
101
|
+
/** Derive a complete coherent scheme from a pushed background color. */
|
|
102
|
+
function schemeFromBackground(background) {
|
|
103
|
+
const parsed = safeParse(background);
|
|
104
|
+
const bg = parsed ? formatHex(parsed) : PAGE_COLOR_DEFAULTS.bg;
|
|
105
|
+
const base = (parsed ? oklch(parsed) : undefined) ?? {
|
|
106
|
+
mode: "oklch",
|
|
107
|
+
l: 0.98,
|
|
108
|
+
c: 0.004,
|
|
109
|
+
h: 80,
|
|
110
|
+
};
|
|
111
|
+
const hue = base.h ?? 0;
|
|
112
|
+
const tint = Math.min(base.c ?? 0, 0.02);
|
|
113
|
+
if (isLightSurface(bg)) {
|
|
114
|
+
return {
|
|
115
|
+
polarity: "light",
|
|
116
|
+
colors: {
|
|
117
|
+
bg,
|
|
118
|
+
card: bg,
|
|
119
|
+
fg: readableOn(PAGE_COLOR_DEFAULTS.fg, bg, FG_CONTRAST),
|
|
120
|
+
muted: readableOn(formatHex({ mode: "oklch", l: 0.55, c: tint, h: hue }), bg, TEXT_CONTRAST),
|
|
121
|
+
border: readableOn(formatHex({ mode: "oklch", l: 0.9, c: Math.min(tint, 0.015), h: hue }), bg, CONTROL_CONTRAST),
|
|
122
|
+
error: readableOn("#a82d1a", bg, TEXT_CONTRAST),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const borderL = clamp(base.l + 0.1, 0.28, 0.45);
|
|
127
|
+
return {
|
|
128
|
+
polarity: "dark",
|
|
129
|
+
colors: {
|
|
130
|
+
bg,
|
|
131
|
+
card: bg,
|
|
132
|
+
fg: readableOn(PAGE_COLOR_DEFAULTS.darkFg, bg, FG_CONTRAST),
|
|
133
|
+
muted: readableOn(formatHex({ mode: "oklch", l: 0.72, c: tint, h: hue }), bg, TEXT_CONTRAST),
|
|
134
|
+
border: readableOn(formatHex({ mode: "oklch", l: borderL, c: tint, h: hue }), bg, CONTROL_CONTRAST),
|
|
135
|
+
error: readableOn("#ff8a7a", bg, TEXT_CONTRAST),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The one entry point: raw theme in, everything the templates paint with out. */
|
|
140
|
+
export function derivePageColors(theme) {
|
|
141
|
+
const accentInput = theme.accent?.trim();
|
|
142
|
+
const accent = safeParse(accentInput) ? accentInput : PAGE_COLOR_DEFAULTS.accent;
|
|
143
|
+
const onAccent = onAccentColor(accent);
|
|
144
|
+
const background = theme.background?.trim();
|
|
145
|
+
if (background && safeParse(background)) {
|
|
146
|
+
const { polarity, colors } = schemeFromBackground(background);
|
|
147
|
+
return {
|
|
148
|
+
scheme: polarity,
|
|
149
|
+
accent,
|
|
150
|
+
onAccent,
|
|
151
|
+
link: readableOn(accent, colors.card, TEXT_CONTRAST),
|
|
152
|
+
pinned: colors,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
scheme: "adaptive",
|
|
157
|
+
accent,
|
|
158
|
+
onAccent,
|
|
159
|
+
link: readableOn(accent, PAGE_COLOR_DEFAULTS.card, TEXT_CONTRAST),
|
|
160
|
+
linkDark: readableOn(accent, PAGE_COLOR_DEFAULTS.darkCard, TEXT_CONTRAST),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Hex form of any schema-accepted color, for color-picker inputs. */
|
|
164
|
+
export function pageColorToHex(value) {
|
|
165
|
+
const parsed = safeParse(value);
|
|
166
|
+
return parsed ? formatHex(parsed) : null;
|
|
167
|
+
}
|