@stigmer/theme 3.1.4 → 3.1.5
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 +35 -73
- package/package.json +1 -1
- package/presets/corporate.css +2 -2
- package/presets/fintech.css +4 -4
- package/presets/friendly.css +8 -8
- package/presets/monochrome.css +4 -4
- package/presets/startup.css +6 -6
- package/src/contract/__tests__/color.test.ts +59 -0
- package/src/contract/__tests__/contrast.test.ts +81 -0
- package/src/contract/__tests__/parse.test.ts +106 -0
- package/src/contract/__tests__/resolve.test.ts +85 -0
- package/src/contract/audit.ts +138 -0
- package/src/contract/color.ts +79 -0
- package/src/contract/pairs.ts +169 -0
- package/src/contract/parse.ts +131 -0
- package/src/contract/resolve.ts +92 -0
- package/src/presets/corporate.css +2 -2
- package/src/presets/fintech.css +4 -4
- package/src/presets/friendly.css +8 -8
- package/src/presets/monochrome.css +4 -4
- package/src/presets/startup.css +6 -6
- package/src/tokens.css +171 -28
- package/tokens.css +171 -28
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseThemeCss } from "../parse.js";
|
|
3
|
+
import { presetLightLeaksIntoDark, resolveTokens } from "../resolve.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = parseThemeCss(`
|
|
6
|
+
:root {
|
|
7
|
+
--stgm-background: light-default;
|
|
8
|
+
--stgm-foreground: light-default;
|
|
9
|
+
--stgm-primary: light-default;
|
|
10
|
+
}
|
|
11
|
+
[data-stgm-color-mode="dark"] {
|
|
12
|
+
--stgm-background: dark-default;
|
|
13
|
+
--stgm-primary: dark-default;
|
|
14
|
+
}
|
|
15
|
+
`);
|
|
16
|
+
|
|
17
|
+
const PRESET = parseThemeCss(`
|
|
18
|
+
.stgm-theme-x {
|
|
19
|
+
--stgm-background: light-preset;
|
|
20
|
+
--stgm-primary: light-preset;
|
|
21
|
+
}
|
|
22
|
+
[data-stgm-color-mode="dark"] .stgm-theme-x,
|
|
23
|
+
.stgm-theme-x[data-stgm-color-mode="dark"] {
|
|
24
|
+
--stgm-background: dark-preset;
|
|
25
|
+
}
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
describe("resolveTokens", () => {
|
|
29
|
+
it("light mode without preset uses :root values", () => {
|
|
30
|
+
const resolved = resolveTokens(DEFAULTS, undefined, "light");
|
|
31
|
+
expect(resolved.get("--stgm-background")).toMatchObject({
|
|
32
|
+
value: "light-default",
|
|
33
|
+
source: "default-light",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("dark mode without preset overlays the dark block, falling back to light", () => {
|
|
38
|
+
const resolved = resolveTokens(DEFAULTS, undefined, "dark");
|
|
39
|
+
expect(resolved.get("--stgm-background")?.value).toBe("dark-default");
|
|
40
|
+
// --stgm-foreground has no dark declaration: inherits the light value.
|
|
41
|
+
expect(resolved.get("--stgm-foreground")).toMatchObject({
|
|
42
|
+
value: "light-default",
|
|
43
|
+
source: "default-light",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("light mode with preset prefers preset-light over defaults", () => {
|
|
48
|
+
const resolved = resolveTokens(DEFAULTS, PRESET, "light");
|
|
49
|
+
expect(resolved.get("--stgm-background")?.value).toBe("light-preset");
|
|
50
|
+
expect(resolved.get("--stgm-foreground")?.value).toBe("light-default");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("dark mode with preset resolves preset-dark ?? preset-light ?? default-dark ?? default-light", () => {
|
|
54
|
+
const resolved = resolveTokens(DEFAULTS, PRESET, "dark");
|
|
55
|
+
// Defined in the preset dark block: preset-dark wins.
|
|
56
|
+
expect(resolved.get("--stgm-background")).toMatchObject({
|
|
57
|
+
value: "dark-preset",
|
|
58
|
+
source: "preset-dark",
|
|
59
|
+
});
|
|
60
|
+
// Defined in preset light only: the preset-light value leaks into dark
|
|
61
|
+
// (higher cascade priority than the default dark block).
|
|
62
|
+
expect(resolved.get("--stgm-primary")).toMatchObject({
|
|
63
|
+
value: "light-preset",
|
|
64
|
+
source: "preset-light",
|
|
65
|
+
});
|
|
66
|
+
// Untouched by the preset: default dark... which doesn't exist for
|
|
67
|
+
// foreground, so default light.
|
|
68
|
+
expect(resolved.get("--stgm-foreground")?.value).toBe("light-default");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("presetLightLeaksIntoDark", () => {
|
|
73
|
+
it("flags tokens the preset defines light-only while defaults have a dark value", () => {
|
|
74
|
+
expect(presetLightLeaksIntoDark(DEFAULTS, PRESET)).toEqual(["--stgm-primary"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not flag tokens with no default dark value (nothing to leak over)", () => {
|
|
78
|
+
const preset = parseThemeCss(`
|
|
79
|
+
.stgm-theme-y {
|
|
80
|
+
--stgm-foreground: light-preset;
|
|
81
|
+
}
|
|
82
|
+
`);
|
|
83
|
+
expect(presetLightLeaksIntoDark(DEFAULTS, preset)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseThemeCss, type ThemeFileTokens } from "./parse.js";
|
|
5
|
+
import {
|
|
6
|
+
presetLightLeaksIntoDark,
|
|
7
|
+
resolveTokens,
|
|
8
|
+
type ColorMode,
|
|
9
|
+
} from "./resolve.js";
|
|
10
|
+
import { contrastRatio, lightnessDelta } from "./color.js";
|
|
11
|
+
import {
|
|
12
|
+
CONTRAST_PAIRS,
|
|
13
|
+
SURFACE_PAIRS,
|
|
14
|
+
SUPPORTING_TEXT_MIN_RATIO,
|
|
15
|
+
SURFACE_MIN_DELTA_L,
|
|
16
|
+
TEXT_MIN_RATIO,
|
|
17
|
+
type ContrastPair,
|
|
18
|
+
} from "./pairs.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Contrast-audit runner: evaluates the declared pair contract against every
|
|
22
|
+
* preset × color mode, resolving tokens through the real cascade.
|
|
23
|
+
*
|
|
24
|
+
* Consumed by the vitest suite (assertions) and by
|
|
25
|
+
* `scripts/contrast-report.ts` (the full human-readable matrix).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
|
|
30
|
+
export const PRESET_IDS = [
|
|
31
|
+
"default",
|
|
32
|
+
"corporate",
|
|
33
|
+
"startup",
|
|
34
|
+
"friendly",
|
|
35
|
+
"fintech",
|
|
36
|
+
"monochrome",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export type PresetId = (typeof PRESET_IDS)[number];
|
|
40
|
+
export const COLOR_MODES: readonly ColorMode[] = ["light", "dark"];
|
|
41
|
+
|
|
42
|
+
export interface PairResult {
|
|
43
|
+
readonly pair: ContrastPair;
|
|
44
|
+
readonly preset: PresetId;
|
|
45
|
+
readonly mode: ColorMode;
|
|
46
|
+
/** WCAG ratio for text pairs; OKLCH lightness delta for surface pairs. */
|
|
47
|
+
readonly measured: number;
|
|
48
|
+
readonly threshold: number;
|
|
49
|
+
readonly passes: boolean;
|
|
50
|
+
/** False when the pair is measured in this mode but not gated (report-only). */
|
|
51
|
+
readonly enforced: boolean;
|
|
52
|
+
readonly foregroundValue: string;
|
|
53
|
+
readonly backgroundValue: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PresetLeak {
|
|
57
|
+
readonly preset: PresetId;
|
|
58
|
+
readonly tokens: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AuditReport {
|
|
62
|
+
readonly results: readonly PairResult[];
|
|
63
|
+
/** Preset tokens whose light value leaks into dark mode (cascade defect). */
|
|
64
|
+
readonly leaks: readonly PresetLeak[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadDefaults(): ThemeFileTokens {
|
|
68
|
+
return parseThemeCss(readFileSync(join(SRC_DIR, "tokens.css"), "utf-8"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadPreset(id: PresetId): ThemeFileTokens | undefined {
|
|
72
|
+
if (id === "default") return undefined;
|
|
73
|
+
return parseThemeCss(
|
|
74
|
+
readFileSync(join(SRC_DIR, "presets", `${id}.css`), "utf-8"),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function thresholdFor(pair: ContrastPair): number {
|
|
79
|
+
switch (pair.kind) {
|
|
80
|
+
case "text":
|
|
81
|
+
return TEXT_MIN_RATIO;
|
|
82
|
+
case "supporting":
|
|
83
|
+
return SUPPORTING_TEXT_MIN_RATIO;
|
|
84
|
+
case "surface":
|
|
85
|
+
return SURFACE_MIN_DELTA_L;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Evaluate the full declared contract. */
|
|
90
|
+
export function runContrastAudit(): AuditReport {
|
|
91
|
+
const defaults = loadDefaults();
|
|
92
|
+
const results: PairResult[] = [];
|
|
93
|
+
const leaks: PresetLeak[] = [];
|
|
94
|
+
|
|
95
|
+
for (const preset of PRESET_IDS) {
|
|
96
|
+
const presetTokens = loadPreset(preset);
|
|
97
|
+
if (presetTokens) {
|
|
98
|
+
const leaked = presetLightLeaksIntoDark(defaults, presetTokens);
|
|
99
|
+
if (leaked.length > 0) leaks.push({ preset, tokens: leaked });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const mode of COLOR_MODES) {
|
|
103
|
+
const resolved = resolveTokens(defaults, presetTokens, mode);
|
|
104
|
+
for (const pair of [...CONTRAST_PAIRS, ...SURFACE_PAIRS]) {
|
|
105
|
+
const foreground = resolved.get(pair.foreground);
|
|
106
|
+
const background = resolved.get(pair.background);
|
|
107
|
+
if (!foreground || !background) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Contract pair references unknown token: ${pair.foreground} / ${pair.background}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const threshold = thresholdFor(pair);
|
|
113
|
+
const measured =
|
|
114
|
+
pair.kind === "surface"
|
|
115
|
+
? lightnessDelta(foreground.value, background.value)
|
|
116
|
+
: contrastRatio(foreground.value, background.value);
|
|
117
|
+
results.push({
|
|
118
|
+
pair,
|
|
119
|
+
preset,
|
|
120
|
+
mode,
|
|
121
|
+
measured,
|
|
122
|
+
threshold,
|
|
123
|
+
passes: measured >= threshold,
|
|
124
|
+
enforced: pair.enforcedModes?.includes(mode) ?? true,
|
|
125
|
+
foregroundValue: foreground.value,
|
|
126
|
+
backgroundValue: background.value,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { results, leaks };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Stable identity for a result, used by the exemption list and reporting. */
|
|
136
|
+
export function resultId(result: PairResult): string {
|
|
137
|
+
return `${result.preset}/${result.mode}: ${result.pair.foreground} on ${result.pair.background}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { converter, parse, wcagContrast, type Rgb } from "culori";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Color math for the token contrast audit, backed by culori.
|
|
5
|
+
*
|
|
6
|
+
* All comparisons happen on *composited* colors: a translucent token (e.g.
|
|
7
|
+
* the dark-mode `--stgm-border: oklch(1 0 0 / 14%)`) is first blended over
|
|
8
|
+
* its surface the way a browser paints it, so the audit measures what the
|
|
9
|
+
* user actually sees.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const toRgb = converter("rgb");
|
|
13
|
+
const toOklch = converter("oklch");
|
|
14
|
+
|
|
15
|
+
/** Parse a CSS color token value; throws on non-color values. */
|
|
16
|
+
export function parseColor(value: string): Rgb {
|
|
17
|
+
const parsed = parse(value);
|
|
18
|
+
if (!parsed) {
|
|
19
|
+
throw new Error(`Not a parseable CSS color: "${value}"`);
|
|
20
|
+
}
|
|
21
|
+
return toRgb(parsed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when the token value is a color (vs. a length, shadow, font, ...). */
|
|
25
|
+
export function isColorValue(value: string): boolean {
|
|
26
|
+
return parse(value) !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Composite a possibly-translucent foreground over an opaque backdrop
|
|
31
|
+
* (simple source-over in sRGB, matching browser compositing).
|
|
32
|
+
*/
|
|
33
|
+
export function compositeOver(foreground: Rgb, backdrop: Rgb): Rgb {
|
|
34
|
+
const alpha = foreground.alpha ?? 1;
|
|
35
|
+
if (alpha >= 1) return foreground;
|
|
36
|
+
return {
|
|
37
|
+
mode: "rgb",
|
|
38
|
+
r: foreground.r * alpha + backdrop.r * (1 - alpha),
|
|
39
|
+
g: foreground.g * alpha + backdrop.g * (1 - alpha),
|
|
40
|
+
b: foreground.b * alpha + backdrop.b * (1 - alpha),
|
|
41
|
+
alpha: 1,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* WCAG 2.1 contrast ratio between two token values.
|
|
47
|
+
*
|
|
48
|
+
* A translucent *background* (e.g. the dark-mode `--stgm-border`) must be
|
|
49
|
+
* composited over the surface it actually paints on — pass that surface as
|
|
50
|
+
* `backdropValue`. The foreground is then composited over the resolved
|
|
51
|
+
* background. Omitting `backdropValue` for a translucent background is a
|
|
52
|
+
* caller error and throws, so no pair silently measures against nothing.
|
|
53
|
+
*/
|
|
54
|
+
export function contrastRatio(
|
|
55
|
+
foregroundValue: string,
|
|
56
|
+
backgroundValue: string,
|
|
57
|
+
backdropValue?: string,
|
|
58
|
+
): number {
|
|
59
|
+
const rawBackground = parseColor(backgroundValue);
|
|
60
|
+
if ((rawBackground.alpha ?? 1) < 1 && backdropValue === undefined) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Background "${backgroundValue}" is translucent — pass the surface it renders over as backdropValue`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const background = backdropValue
|
|
66
|
+
? compositeOver(rawBackground, parseColor(backdropValue))
|
|
67
|
+
: rawBackground;
|
|
68
|
+
const foreground = compositeOver(parseColor(foregroundValue), background);
|
|
69
|
+
return wcagContrast(foreground, background);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Perceptual lightness difference (OKLCH L, 0..1) between two opaque token
|
|
74
|
+
* values. Used for surface-separation checks where WCAG has no defined
|
|
75
|
+
* threshold.
|
|
76
|
+
*/
|
|
77
|
+
export function lightnessDelta(valueA: string, valueB: string): number {
|
|
78
|
+
return Math.abs(toOklch(parseColor(valueA)).l - toOklch(parseColor(valueB)).l);
|
|
79
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared contrast contract: which token pairs must remain readable,
|
|
3
|
+
* and at what threshold.
|
|
4
|
+
*
|
|
5
|
+
* Every pair is grounded in a real rendering in `@stigmer/react` (the
|
|
6
|
+
* `usage` field cites it). The audit resolves both tokens through the real
|
|
7
|
+
* cascade for every preset × color mode and measures the WCAG 2.1 ratio —
|
|
8
|
+
* this file is the single place where a new "text X renders on surface Y"
|
|
9
|
+
* relationship gets registered.
|
|
10
|
+
*
|
|
11
|
+
* Thresholds:
|
|
12
|
+
* - `text` pairs use WCAG 2.1 AA for normal-size text (4.5:1). SDK chrome
|
|
13
|
+
* text is predominantly `text-xs`/`text-sm`, so the large-text relaxation
|
|
14
|
+
* never applies.
|
|
15
|
+
* - `supporting` pairs are deliberately de-emphasized tokens (the `-subtle`
|
|
16
|
+
* / `-faint` foreground variants). They are held to the AA large-text /
|
|
17
|
+
* non-text floor (3:1) — below that, "de-emphasized" becomes "invisible".
|
|
18
|
+
* - `surface` pairs have no WCAG-defined threshold. They are measured as
|
|
19
|
+
* OKLCH lightness delta and asserted against SURFACE_MIN_DELTA_L.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const TEXT_MIN_RATIO = 4.5;
|
|
23
|
+
export const SUPPORTING_TEXT_MIN_RATIO = 3.0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimum OKLCH lightness separation for adjacent surfaces that carry no
|
|
27
|
+
* border (e.g. the user message bubble on the thread background). OKLCH L
|
|
28
|
+
* is perceptually uniform, so one number works for both modes; 0.045 keeps
|
|
29
|
+
* the current light-mode card/page relationship comfortably legal while
|
|
30
|
+
* flagging fills that melt into the page.
|
|
31
|
+
*/
|
|
32
|
+
export const SURFACE_MIN_DELTA_L = 0.045;
|
|
33
|
+
|
|
34
|
+
export type PairKind = "text" | "supporting" | "surface";
|
|
35
|
+
|
|
36
|
+
export interface ContrastPair {
|
|
37
|
+
/** Foreground token (text or fill being read). */
|
|
38
|
+
readonly foreground: string;
|
|
39
|
+
/** Background token it renders on. */
|
|
40
|
+
readonly background: string;
|
|
41
|
+
readonly kind: PairKind;
|
|
42
|
+
/** Where in the SDK this pair is actually rendered. */
|
|
43
|
+
readonly usage: string;
|
|
44
|
+
/**
|
|
45
|
+
* Modes the threshold is enforced in (measured and reported in all modes
|
|
46
|
+
* regardless). Surface pairs enforce dark only: in light mode, ambient
|
|
47
|
+
* light and shadows carry small fill deltas, while dark-mode display
|
|
48
|
+
* flare compresses them — the failure mode reported in issue #187.
|
|
49
|
+
*/
|
|
50
|
+
readonly enforcedModes?: readonly ("light" | "dark")[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pair = (
|
|
54
|
+
foreground: string,
|
|
55
|
+
background: string,
|
|
56
|
+
kind: PairKind,
|
|
57
|
+
usage: string,
|
|
58
|
+
enforcedModes?: readonly ("light" | "dark")[],
|
|
59
|
+
): ContrastPair => ({
|
|
60
|
+
foreground,
|
|
61
|
+
background,
|
|
62
|
+
kind,
|
|
63
|
+
usage,
|
|
64
|
+
...(enforcedModes !== undefined && { enforcedModes }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const STATUSES = [
|
|
68
|
+
"ready",
|
|
69
|
+
"running",
|
|
70
|
+
"pending",
|
|
71
|
+
"degraded",
|
|
72
|
+
"failed",
|
|
73
|
+
"disabled",
|
|
74
|
+
"draft",
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
const SYNTAX = [
|
|
78
|
+
"keyword",
|
|
79
|
+
"property",
|
|
80
|
+
"string",
|
|
81
|
+
"number",
|
|
82
|
+
"bool",
|
|
83
|
+
"atom",
|
|
84
|
+
"meta",
|
|
85
|
+
"tag",
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
export const CONTRAST_PAIRS: readonly ContrastPair[] = [
|
|
89
|
+
// ── Core text on core surfaces ──────────────────────────────────────
|
|
90
|
+
pair("--stgm-foreground", "--stgm-background", "text", "body text everywhere"),
|
|
91
|
+
pair("--stgm-card-foreground", "--stgm-card", "text", "card bodies (ApprovalCard, TodoCard, plan cards)"),
|
|
92
|
+
pair("--stgm-popover-foreground", "--stgm-popover", "text", "dropdowns, dialogs, menus"),
|
|
93
|
+
pair("--stgm-primary-foreground", "--stgm-primary", "text", "primary buttons (composer send, approval Approve)"),
|
|
94
|
+
pair("--stgm-secondary-foreground", "--stgm-secondary", "text", "secondary buttons"),
|
|
95
|
+
pair("--stgm-accent-foreground", "--stgm-accent", "text", "hovered menu items, selected rows"),
|
|
96
|
+
pair("--stgm-muted-foreground", "--stgm-muted", "text", "supporting text in muted panels (ExecutionErrorNotice interrupted state)"),
|
|
97
|
+
pair("--stgm-muted-foreground", "--stgm-background", "text", "Thinking indicator, timestamps, captions (SetupProgress, ThinkingMessage)"),
|
|
98
|
+
pair("--stgm-muted-foreground", "--stgm-card", "text", "supporting text inside cards"),
|
|
99
|
+
pair("--stgm-foreground", "--stgm-muted-subtle", "text", "user message bubble text (MessageEntry HumanMessage), markdown table headers"),
|
|
100
|
+
pair("--stgm-muted-foreground", "--stgm-muted-subtle", "text", "secondary affordances inside the user bubble"),
|
|
101
|
+
|
|
102
|
+
// ── Semantic text ───────────────────────────────────────────────────
|
|
103
|
+
pair("--stgm-destructive-foreground", "--stgm-destructive", "text", "destructive buttons (approval Deny)"),
|
|
104
|
+
pair("--stgm-destructive", "--stgm-destructive-subtle", "text", "error notices (ExecutionErrorNotice failure state)"),
|
|
105
|
+
pair("--stgm-destructive", "--stgm-background", "text", "inline error text (FailedUserMessage)"),
|
|
106
|
+
pair("--stgm-success-foreground", "--stgm-success", "text", "success badges"),
|
|
107
|
+
pair("--stgm-warning-foreground", "--stgm-warning", "text", "warning badges"),
|
|
108
|
+
pair("--stgm-info-foreground", "--stgm-info", "text", "info badges"),
|
|
109
|
+
|
|
110
|
+
// ── Sidebar context ─────────────────────────────────────────────────
|
|
111
|
+
pair("--stgm-sidebar-foreground", "--stgm-sidebar", "text", "sidebar navigation labels"),
|
|
112
|
+
pair("--stgm-sidebar-muted-foreground", "--stgm-sidebar", "text", "sidebar section headers, secondary labels"),
|
|
113
|
+
pair("--stgm-sidebar-primary-foreground", "--stgm-sidebar-primary", "text", "sidebar primary actions"),
|
|
114
|
+
pair("--stgm-sidebar-accent-foreground", "--stgm-sidebar-accent", "text", "sidebar active/hovered items"),
|
|
115
|
+
|
|
116
|
+
// ── Status badges (StatusBadge: status text on its subtle fill) ─────
|
|
117
|
+
...STATUSES.map((status) =>
|
|
118
|
+
pair(
|
|
119
|
+
`--stgm-status-${status}`,
|
|
120
|
+
`--stgm-status-${status}-subtle`,
|
|
121
|
+
"text",
|
|
122
|
+
`StatusBadge "${status}" pill`,
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
...STATUSES.map((status) =>
|
|
126
|
+
pair(
|
|
127
|
+
`--stgm-status-${status}-foreground`,
|
|
128
|
+
`--stgm-status-${status}`,
|
|
129
|
+
"text",
|
|
130
|
+
`solid "${status}" status fill with foreground text`,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
|
|
134
|
+
// ── Diff viewer ─────────────────────────────────────────────────────
|
|
135
|
+
pair("--stgm-diff-added-fg", "--stgm-diff-added-bg", "text", "diff added lines"),
|
|
136
|
+
pair("--stgm-diff-removed-fg", "--stgm-diff-removed-bg", "text", "diff removed lines"),
|
|
137
|
+
pair("--stgm-diff-hunk-header-fg", "--stgm-diff-hunk-header-bg", "text", "diff hunk headers"),
|
|
138
|
+
|
|
139
|
+
// ── Syntax highlighting (code blocks render on bg-muted) ────────────
|
|
140
|
+
...SYNTAX.map((token) =>
|
|
141
|
+
pair(
|
|
142
|
+
`--stgm-syntax-${token}`,
|
|
143
|
+
"--stgm-muted",
|
|
144
|
+
"text",
|
|
145
|
+
`highlighted code "${token}" tokens (markdown-components pre)`,
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
// Comments are deliberately de-emphasized but must stay readable.
|
|
149
|
+
pair("--stgm-syntax-comment", "--stgm-muted", "supporting", "highlighted code comments"),
|
|
150
|
+
|
|
151
|
+
// ── Deliberately de-emphasized text (readability floor, not AA) ─────
|
|
152
|
+
pair("--stgm-muted-foreground-subtle", "--stgm-background", "supporting", "tertiary captions"),
|
|
153
|
+
pair("--stgm-muted-foreground-faint", "--stgm-background", "supporting", "faintest hints (placeholder-level text)"),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Adjacent-surface pairs: fills that must visibly separate from the surface
|
|
158
|
+
* behind them *without* a border — only genuinely borderless fills belong
|
|
159
|
+
* here. Cards, popovers, and buttons separate via border + shadow, so their
|
|
160
|
+
* fill delta is intentionally not part of the contract.
|
|
161
|
+
*
|
|
162
|
+
* Enforced in dark mode only (see ContrastPair.enforcedModes): the light
|
|
163
|
+
* values ship far below the threshold by design and read fine under ambient
|
|
164
|
+
* light; the dark values are where fills melt into the page (issue #187).
|
|
165
|
+
*/
|
|
166
|
+
export const SURFACE_PAIRS: readonly ContrastPair[] = [
|
|
167
|
+
pair("--stgm-muted-subtle", "--stgm-background", "surface", "user message bubble on thread background (borderless)", ["dark"]),
|
|
168
|
+
pair("--stgm-muted", "--stgm-background", "surface", "code blocks, muted panels on the page (borderless)", ["dark"]),
|
|
169
|
+
];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the `--stgm-*` token contract in this package's CSS files.
|
|
3
|
+
*
|
|
4
|
+
* The theme CSS is machine-formatted (one declaration per line, flat
|
|
5
|
+
* top-level blocks, no nesting), so a deliberate line-oriented parser is
|
|
6
|
+
* sufficient and keeps this package dependency-free. It understands the two
|
|
7
|
+
* kinds of files we ship:
|
|
8
|
+
*
|
|
9
|
+
* - `tokens.css` — defaults: `:root { light }` +
|
|
10
|
+
* `[data-stgm-color-mode="dark"] { dark }`
|
|
11
|
+
* - `presets/*.css` — overrides: `.stgm-theme-<id> { light }` +
|
|
12
|
+
* `[data-stgm-color-mode="dark"] .stgm-theme-<id>, ... { dark }`
|
|
13
|
+
*
|
|
14
|
+
* A block is classified `dark` when its selector mentions
|
|
15
|
+
* `data-stgm-color-mode="dark"`, otherwise `light` — the same rule for both
|
|
16
|
+
* file kinds.
|
|
17
|
+
*
|
|
18
|
+
* The parser also captures documentation structure used by the token-docs
|
|
19
|
+
* generator:
|
|
20
|
+
*
|
|
21
|
+
* - `@group` comments (`/* @group Core colors — ... *\/`) start a named
|
|
22
|
+
* group; every following declaration belongs to it until the next header.
|
|
23
|
+
* - A plain comment line immediately preceding a declaration is that
|
|
24
|
+
* token's purpose description.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** One `--stgm-*` custom property declaration with its documentation. */
|
|
28
|
+
export interface TokenDeclaration {
|
|
29
|
+
/** Full custom property name, e.g. `--stgm-background`. */
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** Raw CSS value, e.g. `oklch(0.98 0 0)`. */
|
|
32
|
+
readonly value: string;
|
|
33
|
+
/** Purpose description from the comment line preceding the declaration. */
|
|
34
|
+
readonly description?: string;
|
|
35
|
+
/** Group name from the most recent `@group` header, e.g. `Core colors`. */
|
|
36
|
+
readonly group?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** All `--stgm-*` declarations of one CSS file, split by color mode. */
|
|
40
|
+
export interface ThemeFileTokens {
|
|
41
|
+
readonly light: ReadonlyMap<string, TokenDeclaration>;
|
|
42
|
+
readonly dark: ReadonlyMap<string, TokenDeclaration>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TOKEN_DECLARATION = /^(--stgm-[\w-]+)\s*:\s*(.+?);\s*$/;
|
|
46
|
+
const GROUP_HEADER = /^\/\*\s*@group\s+(.+?)\s*(?:\*\/)?$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a theme CSS file into light/dark token maps.
|
|
50
|
+
*
|
|
51
|
+
* Later blocks of the same mode overwrite earlier ones, mirroring CSS
|
|
52
|
+
* source-order behavior for equal-specificity selectors.
|
|
53
|
+
*/
|
|
54
|
+
export function parseThemeCss(css: string): ThemeFileTokens {
|
|
55
|
+
const light = new Map<string, TokenDeclaration>();
|
|
56
|
+
const dark = new Map<string, TokenDeclaration>();
|
|
57
|
+
|
|
58
|
+
let insideBlock = false;
|
|
59
|
+
let blockIsDark = false;
|
|
60
|
+
let selectorBuffer = "";
|
|
61
|
+
let currentGroup: string | undefined;
|
|
62
|
+
let pendingDescription: string | undefined;
|
|
63
|
+
let insideMultilineComment = false;
|
|
64
|
+
|
|
65
|
+
for (const rawLine of css.split("\n")) {
|
|
66
|
+
const line = rawLine.trim();
|
|
67
|
+
|
|
68
|
+
if (insideMultilineComment) {
|
|
69
|
+
if (line.includes("*/")) insideMultilineComment = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!insideBlock) {
|
|
74
|
+
// Accumulate selector text until the opening brace; a selector list
|
|
75
|
+
// (the preset dark dual selector) can span multiple lines.
|
|
76
|
+
if (line.length === 0) continue;
|
|
77
|
+
if (line.startsWith("/*")) {
|
|
78
|
+
if (!line.includes("*/")) insideMultilineComment = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
selectorBuffer += ` ${line}`;
|
|
82
|
+
if (selectorBuffer.includes("{")) {
|
|
83
|
+
blockIsDark = selectorBuffer.includes('data-stgm-color-mode="dark"');
|
|
84
|
+
insideBlock = true;
|
|
85
|
+
selectorBuffer = "";
|
|
86
|
+
currentGroup = undefined;
|
|
87
|
+
pendingDescription = undefined;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (line.startsWith("}")) {
|
|
93
|
+
insideBlock = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const groupMatch = GROUP_HEADER.exec(line);
|
|
98
|
+
if (groupMatch) {
|
|
99
|
+
currentGroup = groupMatch[1];
|
|
100
|
+
if (!line.includes("*/")) insideMultilineComment = true;
|
|
101
|
+
pendingDescription = undefined;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (line.startsWith("/*")) {
|
|
106
|
+
if (!line.includes("*/")) {
|
|
107
|
+
insideMultilineComment = true;
|
|
108
|
+
pendingDescription = undefined;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
pendingDescription = line.replace(/^\/\*\s*/, "").replace(/\s*\*\/$/, "");
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const declMatch = TOKEN_DECLARATION.exec(line);
|
|
116
|
+
if (declMatch) {
|
|
117
|
+
const declaration: TokenDeclaration = {
|
|
118
|
+
name: declMatch[1],
|
|
119
|
+
value: declMatch[2],
|
|
120
|
+
...(pendingDescription !== undefined && { description: pendingDescription }),
|
|
121
|
+
...(currentGroup !== undefined && { group: currentGroup }),
|
|
122
|
+
};
|
|
123
|
+
(blockIsDark ? dark : light).set(declaration.name, declaration);
|
|
124
|
+
}
|
|
125
|
+
// Any non-comment line (declaration or otherwise) consumes the pending
|
|
126
|
+
// description — a comment only documents the line directly beneath it.
|
|
127
|
+
pendingDescription = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { light, dark };
|
|
131
|
+
}
|