@gtk-js/gtk-css 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @gtk-js/gtk-css
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0982123: Bump all packages past burned npm versions
8
+
9
+ ## 0.2.0
10
+
11
+ ## 0.1.0
12
+
13
+ ### Minor Changes
14
+
15
+ - f9da446: Testing CI publishing
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gtk-js/gtk-css",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.1.1",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./compile": "./src/compile.ts"
12
+ },
13
+ "dependencies": {
14
+ "postcss": "^8.5.8",
15
+ "sass": "^1.99.0"
16
+ }
17
+ }
package/src/compile.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from "fs";
2
+ import { type Plugin } from "postcss";
3
+ import { buildGtkToWeb, preprocess } from "./transform.ts";
4
+
5
+ export interface CompileOptions {
6
+ /**
7
+ * Filter output to a single color scheme.
8
+ * - "light": strip dark media queries and [data-color-scheme="dark"] rules
9
+ * - "dark": use dark color values as base, strip all [data-color-scheme] rules
10
+ * - omit: emit both (current behavior — @media + [data-color-scheme] overrides)
11
+ */
12
+ scheme?: "light" | "dark";
13
+ /**
14
+ * Override the default accent color (#3584e4) baked into the compiled CSS.
15
+ * Useful when a theme has a fixed accent per variant.
16
+ */
17
+ accentColor?: string;
18
+ /**
19
+ * Maps virtual URL paths (as used in CSS url()) to absolute filesystem paths.
20
+ * When provided, -gtk-scaled(url('path.png'), ...) declarations are replaced
21
+ * with embedded base64 data URIs by reading from the mapped filesystem path.
22
+ *
23
+ * Build this from the theme's gtk.gresource.xml — each <file> entry's virtual
24
+ * path maps to the corresponding physical file on disk.
25
+ *
26
+ * Example: { "windows-assets/titlebutton-close.png": "/abs/path/to/titlebutton-close.png" }
27
+ */
28
+ assetMap?: Map<string, string>;
29
+ }
30
+
31
+ function makeGtkAssetPlugin(assetMap: Map<string, string>): Plugin {
32
+ return {
33
+ postcssPlugin: "gtk-embed-asset-functions",
34
+ Declaration(decl) {
35
+ if (!decl.value.includes("-gtk-scaled(")) return;
36
+ const scaledMatch = decl.value.match(/-gtk-scaled\(\s*url\(['"]?([^'")\s]+)['"]?\)/);
37
+ if (!scaledMatch || !scaledMatch[1]) {
38
+ decl.remove();
39
+ return;
40
+ }
41
+ const virtualPath = scaledMatch[1];
42
+ const absPath = assetMap.get(virtualPath);
43
+ if (!absPath) {
44
+ throw new Error(`Asset not found in assetMap: ${virtualPath}`);
45
+ }
46
+ const b64 = readFileSync(absPath).toString("base64");
47
+ decl.value = `url('data:image/png;base64,${b64}')`;
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Compiles a GTK/libadwaita SCSS file to web-compatible CSS.
54
+ *
55
+ * Uses sassc (the C implementation of Sass) to match upstream's build system,
56
+ * then runs text preprocessing + PostCSS transforms to convert GTK-specific
57
+ * CSS to web-compatible CSS.
58
+ */
59
+ export async function compileGtkCSS(scssPath: string, options?: CompileOptions): Promise<string> {
60
+ // Step 1: Compile SCSS → raw CSS using sassc (matches upstream)
61
+ const result = Bun.spawnSync({
62
+ cmd: ["sassc", "-t", "expanded", scssPath],
63
+ stdout: "pipe",
64
+ stderr: "pipe",
65
+ });
66
+
67
+ if (result.exitCode !== 0) {
68
+ throw new Error(
69
+ `sassc compilation failed (exit ${result.exitCode}):\n${result.stderr.toString()}`,
70
+ );
71
+ }
72
+
73
+ const rawCSS = result.stdout.toString();
74
+
75
+ // Step 2: Text preprocessing — strip @define-color and other non-CSS syntax
76
+ const preprocessed = preprocess(rawCSS, options);
77
+
78
+ // Step 3: PostCSS transforms — remap selectors, pseudo-classes, properties
79
+ const assetPlugin = options?.assetMap ? makeGtkAssetPlugin(options.assetMap) : undefined;
80
+ const pipeline = buildGtkToWeb(assetPlugin);
81
+ const transformed = await pipeline.process(preprocessed, { from: scssPath });
82
+
83
+ return transformed.css;
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { ALL_SELECTORS, INTERNAL_SELECTORS, WIDGET_SELECTORS } from "./selectors.ts";
2
+ export { GtkTheme, resolveColorScheme } from "./theme.ts";
3
+ export { gtkToWeb, preprocess } from "./transform.ts";
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Mapping of bare GTK CSS node names to web CSS class selectors.
3
+ *
4
+ * GTK CSS targets a widget node tree where selectors like `button` or `headerbar`
5
+ * refer to widget types, not HTML elements. On the web, we remap these to class
6
+ * selectors so that React components can apply the matching className.
7
+ *
8
+ * Some names (like `button`, `label`, `image`) conflict with HTML elements,
9
+ * so ALL bare selectors get a `.gtk-` prefix for consistency.
10
+ */
11
+
12
+ // Top-level widgets
13
+ export const WIDGET_SELECTORS: Record<string, string> = {
14
+ // Buttons
15
+ button: ".gtk-button",
16
+ menubutton: ".gtk-menubutton",
17
+ splitbutton: ".gtk-splitbutton",
18
+ buttoncontent: ".gtk-buttoncontent",
19
+ tabbutton: ".gtk-tabbutton",
20
+ modelbutton: ".gtk-modelbutton",
21
+ colorbutton: ".gtk-colorbutton",
22
+ fontbutton: ".gtk-fontbutton",
23
+ appchooserbutton: ".gtk-appchooserbutton",
24
+ // Entries
25
+ entry: ".gtk-entry",
26
+ text: ".gtk-text",
27
+ editablelabel: ".gtk-editablelabel",
28
+ // Layout
29
+ box: ".gtk-box",
30
+ grid: ".gtk-grid",
31
+ headerbar: ".gtk-headerbar",
32
+ windowcontrols: ".gtk-windowcontrols",
33
+ windowtitle: ".gtk-windowtitle",
34
+ windowhandle: ".gtk-windowhandle",
35
+ actionbar: ".gtk-actionbar",
36
+ searchbar: ".gtk-searchbar",
37
+ toolbar: ".gtk-toolbar",
38
+ centerbox: ".gtk-centerbox",
39
+ viewport: ".gtk-viewport",
40
+ // Lists
41
+ listview: ".gtk-listview",
42
+ list: ".gtk-list",
43
+ row: ".gtk-row",
44
+ columnview: ".gtk-columnview",
45
+ gridview: ".gtk-gridview",
46
+ flowbox: ".gtk-flowbox",
47
+ flowboxchild: ".gtk-flowboxchild",
48
+ iconview: ".gtk-iconview",
49
+ treeview: ".gtk-treeview",
50
+ treeexpander: ".gtk-treeexpander",
51
+ // Containers
52
+ notebook: ".gtk-notebook",
53
+ stack: ".gtk-stack",
54
+ stackswitcher: ".gtk-stackswitcher",
55
+ stacksidebar: ".gtk-stacksidebar",
56
+ overlay: ".gtk-overlay",
57
+ frame: ".gtk-frame",
58
+ expander: ".gtk-expander",
59
+ "expander-widget": ".gtk-expander-widget",
60
+ paned: ".gtk-paned",
61
+ scrolledwindow: ".gtk-scrolledwindow",
62
+ revealer: ".gtk-revealer",
63
+ clamp: ".gtk-clamp",
64
+ // Controls
65
+ scale: ".gtk-scale",
66
+ scrollbar: ".gtk-scrollbar",
67
+ range: ".gtk-range",
68
+ switch: ".gtk-switch",
69
+ spinbutton: ".gtk-spinbutton",
70
+ checkbutton: ".gtk-checkbutton",
71
+ dropdown: ".gtk-dropdown",
72
+ combobox: ".gtk-combobox",
73
+ // Display
74
+ label: ".gtk-label",
75
+ image: ".gtk-image",
76
+ picture: ".gtk-picture",
77
+ video: ".gtk-video",
78
+ separator: ".gtk-separator",
79
+ spinner: ".gtk-spinner",
80
+ progressbar: ".gtk-progressbar",
81
+ levelbar: ".gtk-levelbar",
82
+ calendar: ".gtk-calendar",
83
+ infobar: ".gtk-infobar",
84
+ statusbar: ".gtk-statusbar",
85
+ textview: ".gtk-textview",
86
+ // Popover/Menu
87
+ popover: ".gtk-popover",
88
+ menubar: ".gtk-menubar",
89
+ // Windows/Dialogs
90
+ window: ".gtk-window",
91
+ dialog: ".gtk-dialog",
92
+ "dialog-host": ".gtk-dialog-host",
93
+ messagedialog: ".gtk-messagedialog",
94
+ // Color
95
+ colorswatch: ".gtk-colorswatch",
96
+ colorchooser: ".gtk-colorchooser",
97
+ // Emoji
98
+ emojichooser: ".gtk-emojichooser",
99
+ emoji: ".gtk-emoji",
100
+ "emoji-completion-row": ".gtk-emoji-completion-row",
101
+ // File
102
+ filechooser: ".gtk-filechooser",
103
+ filelistcell: ".gtk-filelistcell",
104
+ filethumbnail: ".gtk-filethumbnail",
105
+ pathbar: ".gtk-pathbar",
106
+ placessidebar: ".gtk-placessidebar",
107
+ placesview: ".gtk-placesview",
108
+ // Toggle group
109
+ "toggle-group": ".gtk-toggle-group",
110
+ toggle: ".gtk-toggle",
111
+ // Tooltip
112
+ tooltip: ".gtk-tooltip",
113
+ // Print
114
+ paper: ".gtk-paper",
115
+ // Shortcuts
116
+ shortcut: ".gtk-shortcut",
117
+ "shortcut-label": ".gtk-shortcut-label",
118
+ "shortcuts-section": ".gtk-shortcuts-section",
119
+ // Adwaita-specific widgets
120
+ avatar: ".gtk-avatar",
121
+ banner: ".gtk-banner",
122
+ "status-page": ".gtk-status-page",
123
+ statuspage: ".gtk-statuspage",
124
+ "bottom-sheet": ".gtk-bottom-sheet",
125
+ "adaptive-preview": ".gtk-adaptive-preview",
126
+ "tab-bar": ".gtk-tab-bar",
127
+ tabbar: ".gtk-tabbar",
128
+ "tab-overview": ".gtk-tab-overview",
129
+ taboverview: ".gtk-taboverview",
130
+ tabview: ".gtk-tabview",
131
+ tabbox: ".gtk-tabbox",
132
+ tabboxchild: ".gtk-tabboxchild",
133
+ tabgrid: ".gtk-tabgrid",
134
+ tabgridchild: ".gtk-tabgridchild",
135
+ tabthumbnail: ".gtk-tabthumbnail",
136
+ "inline-view-switcher": ".gtk-inline-view-switcher",
137
+ viewswitcher: ".gtk-viewswitcher",
138
+ viewswitcherbar: ".gtk-viewswitcherbar",
139
+ viewswitchertitle: ".gtk-viewswitchertitle",
140
+ "navigation-view": ".gtk-navigation-view",
141
+ "overlay-split-view": ".gtk-overlay-split-view",
142
+ leaflet: ".gtk-leaflet",
143
+ flap: ".gtk-flap",
144
+ "floating-sheet": ".gtk-floating-sheet",
145
+ sheet: ".gtk-sheet",
146
+ toolbarview: ".gtk-toolbarview",
147
+ preferencesgroup: ".gtk-preferencesgroup",
148
+ preferencespage: ".gtk-preferencespage",
149
+ toast: ".gtk-toast",
150
+ indicatorbin: ".gtk-indicatorbin",
151
+ };
152
+
153
+ // Internal structural elements (parts of widgets, not standalone)
154
+ export const INTERNAL_SELECTORS: Record<string, string> = {
155
+ trough: ".gtk-trough",
156
+ slider: ".gtk-slider",
157
+ highlight: ".gtk-highlight",
158
+ fill: ".gtk-fill",
159
+ marks: ".gtk-marks",
160
+ mark: ".gtk-mark",
161
+ indicator: ".gtk-indicator",
162
+ value: ".gtk-value",
163
+ arrow: ".gtk-arrow",
164
+ contents: ".gtk-contents",
165
+ header: ".gtk-header",
166
+ tabs: ".gtk-tabs",
167
+ tab: ".gtk-tab",
168
+ undershoot: ".gtk-undershoot",
169
+ overshoot: ".gtk-overshoot",
170
+ dimming: ".gtk-dimming",
171
+ shadow: ".gtk-shadow",
172
+ border: ".gtk-border",
173
+ "block-cursor": ".gtk-block-cursor",
174
+ placeholder: ".gtk-placeholder",
175
+ progress: ".gtk-progress",
176
+ cell: ".gtk-cell",
177
+ check: ".gtk-check",
178
+ radio: ".gtk-radio",
179
+ dnd: ".gtk-dnd",
180
+ selection: ".gtk-selection",
181
+ block: ".gtk-block",
182
+ title: ".gtk-title",
183
+ item: ".gtk-item",
184
+ color: ".gtk-color",
185
+ widget: ".gtk-widget",
186
+ "cursor-handle": ".gtk-cursor-handle",
187
+ "drag-handle": ".gtk-drag-handle",
188
+ dndtarget: ".gtk-dndtarget",
189
+ drawing: ".gtk-drawing",
190
+ magnifier: ".gtk-magnifier",
191
+ rubberband: ".gtk-rubberband",
192
+ "sort-indicator": ".gtk-sort-indicator",
193
+ spin: ".gtk-spin",
194
+ plane: ".gtk-plane",
195
+ acceleditor: ".gtk-acceleditor",
196
+ accelerator: ".gtk-accelerator",
197
+ };
198
+
199
+ /** All selector mappings combined. */
200
+ export const ALL_SELECTORS: Record<string, string> = {
201
+ ...WIDGET_SELECTORS,
202
+ ...INTERNAL_SELECTORS,
203
+ };
package/src/theme.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Abstract base class for GTK themes.
3
+ *
4
+ * Subclasses own all state (color scheme, accent, variants, etc.)
5
+ * and return the full CSS string via getCSS().
6
+ *
7
+ * Third parties can subclass this with zero dependency on React or widgets.
8
+ */
9
+ export abstract class GtkTheme {
10
+ abstract getCSS(): string;
11
+ }
12
+
13
+ /** Resolve "auto" color scheme to "light" or "dark" using prefers-color-scheme. */
14
+ export function resolveColorScheme(scheme: "light" | "dark" | "auto"): "light" | "dark" {
15
+ if (scheme !== "auto") return scheme;
16
+ if (
17
+ typeof window !== "undefined" &&
18
+ window.matchMedia?.("(prefers-color-scheme: dark)").matches
19
+ ) {
20
+ return "dark";
21
+ }
22
+ return "light";
23
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { gtkToWeb } from "./transform.ts";
3
+
4
+ async function transform(css: string) {
5
+ const result = await gtkToWeb.process(css, { from: undefined });
6
+ return result.css;
7
+ }
8
+
9
+ describe("remapPseudoClasses", () => {
10
+ test(":checked → [data-checked]", async () => {
11
+ const out = await transform(".gtk-button:checked { color: red; }");
12
+ expect(out).toContain(".gtk-button[data-checked]");
13
+ expect(out).not.toContain(":checked");
14
+ });
15
+
16
+ test(":visited → [data-visited]", async () => {
17
+ const out = await transform(".gtk-button.link:visited { color: blue; }");
18
+ expect(out).toContain(".gtk-button.link[data-visited]");
19
+ expect(out).not.toContain(":visited");
20
+ });
21
+
22
+ test(":backdrop → [data-backdrop]", async () => {
23
+ const out = await transform(".gtk-window:backdrop { opacity: 0.5; }");
24
+ expect(out).toContain(".gtk-window[data-backdrop]");
25
+ expect(out).not.toContain(":backdrop");
26
+ });
27
+
28
+ test(":drop(active) → [data-drop-active]", async () => {
29
+ const out = await transform(".gtk-row:drop(active) { background: blue; }");
30
+ expect(out).toContain(".gtk-row[data-drop-active]");
31
+ expect(out).not.toContain(":drop(active)");
32
+ });
33
+
34
+ test(":selected → [aria-selected]", async () => {
35
+ const out = await transform(".gtk-row:selected { background: blue; }");
36
+ expect(out).toContain('[aria-selected="true"]');
37
+ expect(out).not.toContain(":selected");
38
+ });
39
+ });
40
+
41
+ describe("convertGtkColorFunctions", () => {
42
+ describe("alpha()", () => {
43
+ test("hex color → rgba()", async () => {
44
+ const out = await transform(".x { color: alpha(#242424, 0.25); }");
45
+ expect(out).toContain("rgba(36, 36, 36, 0.25)");
46
+ expect(out).not.toContain("alpha(");
47
+ });
48
+
49
+ test("3-digit hex → rgba()", async () => {
50
+ const out = await transform(".x { color: alpha(#fff, 0.5); }");
51
+ expect(out).toContain("rgba(255, 255, 255, 0.5)");
52
+ });
53
+
54
+ test("named color black → rgba()", async () => {
55
+ const out = await transform(".x { color: alpha(black, 0.8); }");
56
+ expect(out).toContain("rgba(0, 0, 0, 0.8)");
57
+ });
58
+
59
+ test("named color white → rgba()", async () => {
60
+ const out = await transform(".x { color: alpha(white, 0.5); }");
61
+ expect(out).toContain("rgba(255, 255, 255, 0.5)");
62
+ });
63
+
64
+ test("currentColor → color-mix()", async () => {
65
+ const out = await transform(".x { color: alpha(currentColor, 0.06); }");
66
+ expect(out).toContain("color-mix(in oklab, currentColor 6%, transparent)");
67
+ expect(out).not.toContain("alpha(");
68
+ });
69
+
70
+ test("single arg (identity) → passthrough", async () => {
71
+ const out = await transform(".x { color: alpha(black); }");
72
+ expect(out).toContain("color: black");
73
+ expect(out).not.toContain("alpha(");
74
+ });
75
+
76
+ test("nested in linear-gradient()", async () => {
77
+ const out = await transform(
78
+ ".x { background-image: linear-gradient(alpha(#242424, 0.25), alpha(#242424, 0.35)); }",
79
+ );
80
+ expect(out).toContain("rgba(36, 36, 36, 0.25)");
81
+ expect(out).toContain("rgba(36, 36, 36, 0.35)");
82
+ expect(out).not.toContain("alpha(");
83
+ });
84
+
85
+ test("multiple in one declaration", async () => {
86
+ const out = await transform(
87
+ ".x { box-shadow: inset 0 0 0 1px alpha(currentColor, 0.04), inset 0 1px alpha(currentColor, 0.05); }",
88
+ );
89
+ expect(out).not.toContain("alpha(");
90
+ expect(out).toContain("color-mix(in oklab, currentColor 4%, transparent)");
91
+ expect(out).toContain("color-mix(in oklab, currentColor 5%, transparent)");
92
+ });
93
+
94
+ test("rgba() arg → color-mix()", async () => {
95
+ const out = await transform(".x { color: alpha(rgba(0, 0, 0, 0.08), 0.75); }");
96
+ expect(out).toContain("color-mix(in oklab, rgba(0, 0, 0, 0.08) 75%, transparent)");
97
+ expect(out).not.toContain("alpha(");
98
+ });
99
+
100
+ test("does not match inside another function name", async () => {
101
+ // "get-alpha(" should not be matched
102
+ const out = await transform(".x { color: get-alpha(test); }");
103
+ expect(out).toContain("get-alpha(test)");
104
+ });
105
+
106
+ test("unexpected arg count throws", async () => {
107
+ expect(transform(".x { color: alpha(a, b, c); }")).rejects.toThrow();
108
+ });
109
+ });
110
+
111
+ describe("mix()", () => {
112
+ test("two hex colors → color-mix()", async () => {
113
+ const out = await transform(".x { color: mix(#FFFFFF, #0860F2, 0.75); }");
114
+ expect(out).toContain("color-mix(in oklab, #0860F2 75%, #FFFFFF)");
115
+ expect(out).not.toMatch(/(?<!color-)mix\(/);
116
+ });
117
+
118
+ test("hex + rgba → color-mix()", async () => {
119
+ const out = await transform(
120
+ ".x { background-color: mix(#FFFFFF, rgba(0, 0, 0, 0.87), 0.15); }",
121
+ );
122
+ expect(out).toContain("color-mix(in oklab, rgba(0, 0, 0, 0.87) 15%, #FFFFFF)");
123
+ expect(out).not.toMatch(/(?<!color-)mix\(/);
124
+ });
125
+
126
+ test("unexpected arg count throws", async () => {
127
+ expect(transform(".x { color: mix(a, b); }")).rejects.toThrow();
128
+ });
129
+ });
130
+
131
+ describe("shade()", () => {
132
+ test("lighten hex → pre-computed rgb()", async () => {
133
+ const out = await transform(".x { color: shade(#5e5c64, 1.2); }");
134
+ // 94*1.2=113, 92*1.2=110, 100*1.2=120
135
+ expect(out).toContain("rgb(113, 110, 120)");
136
+ expect(out).not.toContain("shade(");
137
+ });
138
+
139
+ test("darken hex → pre-computed rgb()", async () => {
140
+ const out = await transform(".x { color: shade(#27a66c, 0.95); }");
141
+ // 39*0.95=37, 166*0.95=158, 108*0.95=103
142
+ expect(out).toContain("rgb(37, 158, 103)");
143
+ expect(out).not.toContain("shade(");
144
+ });
145
+
146
+ test("clamps to 255", async () => {
147
+ const out = await transform(".x { color: shade(#f9e2a7, 1.07); }");
148
+ // 249*1.07=255(clamped), 226*1.07=242, 167*1.07=179
149
+ expect(out).toContain("rgb(255, 242, 179)");
150
+ });
151
+
152
+ test("dynamic color lighten → color-mix with white", async () => {
153
+ const out = await transform(".x { color: shade(var(--c), 1.2); }");
154
+ expect(out).toContain("color-mix(in oklab, var(--c), white");
155
+ expect(out).not.toContain("shade(");
156
+ });
157
+
158
+ test("dynamic color darken → color-mix with black", async () => {
159
+ const out = await transform(".x { color: shade(var(--c), 0.8); }");
160
+ expect(out).toContain("color-mix(in oklab, var(--c), black");
161
+ expect(out).not.toContain("shade(");
162
+ });
163
+
164
+ test("unexpected arg count throws", async () => {
165
+ expect(transform(".x { color: shade(a); }")).rejects.toThrow();
166
+ });
167
+
168
+ test("non-numeric factor throws", async () => {
169
+ expect(transform(".x { color: shade(#fff, abc); }")).rejects.toThrow();
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,642 @@
1
+ import postcss, { type Plugin } from "postcss";
2
+ import type { CompileOptions } from "./compile.ts";
3
+ import { ALL_SELECTORS } from "./selectors.ts";
4
+
5
+ /**
6
+ * Pre-processes raw sassc output to remove GTK-specific syntax that
7
+ * isn't valid CSS and would prevent PostCSS from parsing.
8
+ *
9
+ * This is a text-based pass that runs BEFORE PostCSS parsing.
10
+ */
11
+ export function preprocess(rawCSS: string, options?: CompileOptions): string {
12
+ const lines = rawCSS.split("\n");
13
+ const { scheme, accentColor } = options ?? {};
14
+
15
+ // Pass 1: Collect @define-color values, separating light (top-level) from
16
+ // dark (inside @media (prefers-color-scheme: dark)).
17
+ // Light values are used as defaults when resolving @name references.
18
+ const accentSeed = accentColor ?? "#3584e4";
19
+ const lightColors = new Map<string, string>([
20
+ // Runtime-only colors set by adw-style-manager.c, never in stylesheet
21
+ ["accent_bg_color", accentSeed],
22
+ ["accent_fg_color", "white"],
23
+ ]);
24
+ const darkColors = new Map<string, string>([
25
+ ["accent_bg_color", accentSeed],
26
+ ["accent_fg_color", "white"],
27
+ ]);
28
+
29
+ let insideDarkMedia = false;
30
+ let braceDepth = 0;
31
+
32
+ for (const line of lines) {
33
+ const trimmed = line.trim();
34
+
35
+ // Track whether we're inside a @media (prefers-color-scheme: dark) block
36
+ if (trimmed.includes("prefers-color-scheme: dark")) {
37
+ insideDarkMedia = true;
38
+ braceDepth = 0;
39
+ }
40
+ if (insideDarkMedia) {
41
+ for (const ch of trimmed) {
42
+ if (ch === "{") braceDepth++;
43
+ if (ch === "}") braceDepth--;
44
+ }
45
+ if (braceDepth <= 0 && trimmed.includes("}")) {
46
+ insideDarkMedia = false;
47
+ }
48
+ }
49
+
50
+ // Collect @define-color declarations
51
+ const defineMatches = trimmed.matchAll(/@define-color\s+(\S+)\s+([^;]+);/g);
52
+ for (const m of defineMatches) {
53
+ if (insideDarkMedia) {
54
+ darkColors.set(m[1]!, m[2]!);
55
+ } else {
56
+ lightColors.set(m[1]!, m[2]!);
57
+ if (!darkColors.has(m[1]!)) {
58
+ darkColors.set(m[1]!, m[2]!);
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // Resolve @name references within a color value using the given color map
65
+ function resolveColorRef(value: string, colors: Map<string, string>, depth = 0): string {
66
+ if (depth > 10) return value; // prevent infinite recursion
67
+ return value.replace(/@([a-z][a-z0-9]*(?:_[a-z0-9]+)*)/g, (_m, name: string) => {
68
+ const resolved = colors.get(name);
69
+ if (resolved) return resolveColorRef(resolved, colors, depth + 1);
70
+ return `var(--${name.replace(/_/g, "-")})`;
71
+ });
72
+ }
73
+
74
+ // Pass 2: Strip @define-color lines and resolve @name references.
75
+ const output: string[] = [];
76
+ for (const line of lines) {
77
+ const trimmed = line.trim();
78
+
79
+ // Strip @define-color lines
80
+ if (trimmed.startsWith("@define-color ")) {
81
+ continue;
82
+ }
83
+
84
+ // Handle @define-color inside @media blocks
85
+ if (trimmed.includes("@define-color ")) {
86
+ const cleaned = line.replace(/@define-color\s+\S+\s+[^;]+;\s*/g, "");
87
+ const inner = cleaned.replace(/@media\s*\([^)]+\)\s*\{\s*\}/, "").trim();
88
+ if (inner === "" || inner === "}" || cleaned.trim() === "}") {
89
+ if (cleaned.trim() === "}") {
90
+ output.push(cleaned);
91
+ }
92
+ continue;
93
+ }
94
+ output.push(cleaned);
95
+ continue;
96
+ }
97
+
98
+ output.push(line);
99
+ }
100
+
101
+ let result = output.join("\n");
102
+
103
+ // Convert remaining GTK @color_name references to resolved values.
104
+ // Uses light colors as default since they appear at the top level.
105
+ // Must NOT match @media, @keyframes, @import, etc. — those have no underscores.
106
+ result = result.replace(/@([a-z][a-z0-9]*(?:_[a-z0-9]+)+)/g, (_match, name: string) => {
107
+ const defined = lightColors.get(name);
108
+ if (defined) {
109
+ return resolveColorRef(defined, lightColors);
110
+ }
111
+ return `var(--${name.replace(/_/g, "-")})`;
112
+ });
113
+
114
+ // Collect per-scheme color variable differences
115
+ const darkOverrides: string[] = [];
116
+ for (const [name, darkVal] of darkColors) {
117
+ const lightVal = lightColors.get(name);
118
+ const resolvedDark = resolveColorRef(darkVal, darkColors);
119
+ const resolvedLight = lightVal ? resolveColorRef(lightVal, lightColors) : null;
120
+ if (resolvedDark !== resolvedLight) {
121
+ darkOverrides.push(` --${name.replace(/_/g, "-")}: ${resolvedDark};`);
122
+ }
123
+ }
124
+
125
+ if (scheme === "dark") {
126
+ // Dark-only output: rewrite base :root variables to dark values, emit nothing else
127
+ if (darkOverrides.length > 0) {
128
+ result += `\n:root {\n${darkOverrides.join("\n")}\n}\n`;
129
+ }
130
+ } else if (scheme === "light") {
131
+ // Light-only output: no dark overrides at all — light values are already the base
132
+ } else {
133
+ // Default: emit both @media and [data-color-scheme] overrides (auto behavior)
134
+ if (darkOverrides.length > 0) {
135
+ result += `\n@media (prefers-color-scheme: dark) {\n :root {\n${darkOverrides.join("\n")}\n }\n}\n`;
136
+ result += `\n[data-gtk-provider][data-color-scheme="dark"] {\n${darkOverrides.join("\n")}\n}\n`;
137
+ }
138
+
139
+ const lightOverrides = [...lightColors.entries()]
140
+ .filter(([name]) => darkColors.has(name))
141
+ .map(
142
+ ([name, val]) => ` --${name.replace(/_/g, "-")}: ${resolveColorRef(val, lightColors)};`,
143
+ );
144
+ if (lightOverrides.length > 0) {
145
+ result += `\n[data-gtk-provider][data-color-scheme="light"] {\n${lightOverrides.join("\n")}\n}\n`;
146
+ }
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Builds a regex that matches bare GTK selectors as whole words in a CSS selector string.
154
+ * Matches `button` but not `.button` or `#button` or `-button`.
155
+ */
156
+ function buildSelectorRegex(): RegExp {
157
+ // Sort by length descending so longer names match first (e.g. "toggle-group" before "toggle")
158
+ const names = Object.keys(ALL_SELECTORS).sort((a, b) => b.length - a.length);
159
+ // Match bare names that are:
160
+ // - At the start of the selector, or preceded by whitespace, >, +, ~, or comma
161
+ // - NOT preceded by . # - or another letter (which would mean it's a class, id, or part of another word)
162
+ const pattern = names.map((n) => n.replace(/-/g, "\\-")).join("|");
163
+ return new RegExp(`(?<![.#\\w-])(${pattern})(?=[.#:\\[\\s>,+~){]|$)`, "g");
164
+ }
165
+
166
+ const selectorRegex = buildSelectorRegex();
167
+
168
+ /**
169
+ * PostCSS plugin: Remap bare GTK widget selectors to .gtk-* class selectors.
170
+ */
171
+ const remapSelectors: Plugin = {
172
+ postcssPlugin: "gtk-remap-selectors",
173
+ Rule(rule) {
174
+ const original = rule.selector;
175
+ const remapped = original.replace(selectorRegex, (match) => {
176
+ return ALL_SELECTORS[match] ?? match;
177
+ });
178
+ if (remapped !== original) {
179
+ rule.selector = remapped;
180
+ }
181
+ },
182
+ };
183
+
184
+ /**
185
+ * PostCSS plugin: Remap GTK-specific pseudo-classes to data attributes.
186
+ */
187
+ const remapPseudoClasses: Plugin = {
188
+ postcssPlugin: "gtk-remap-pseudo-classes",
189
+ Rule(rule) {
190
+ let sel = rule.selector;
191
+
192
+ // :backdrop → [data-backdrop]
193
+ sel = sel.replace(/:backdrop/g, "[data-backdrop]");
194
+
195
+ // :drop(active) → [data-drop-active]
196
+ sel = sel.replace(/:drop\(active\)/g, "[data-drop-active]");
197
+
198
+ // :selected → [aria-selected="true"]
199
+ sel = sel.replace(/:selected/g, '[aria-selected="true"]');
200
+
201
+ // :checked → [data-checked] (GTK uses checked state for toggles/spinners)
202
+ sel = sel.replace(/:checked/g, "[data-checked]");
203
+
204
+ // :indeterminate → [data-indeterminate] (web <span> never matches native :indeterminate)
205
+ sel = sel.replace(/:indeterminate/g, "[data-indeterminate]");
206
+
207
+ // :visited → [data-visited] (browsers restrict :visited styling; use data attribute)
208
+ sel = sel.replace(/:visited/g, "[data-visited]");
209
+
210
+ // :disabled → [data-disabled] (<span> doesn't support :disabled natively)
211
+ sel = sel.replace(/:disabled/g, "[data-disabled]");
212
+
213
+ if (sel !== rule.selector) {
214
+ rule.selector = sel;
215
+ }
216
+ },
217
+ };
218
+
219
+ /**
220
+ * PostCSS plugin: Handle -gtk-* vendor properties.
221
+ */
222
+ const handleGtkProperties: Plugin = {
223
+ postcssPlugin: "gtk-handle-properties",
224
+ Declaration(decl) {
225
+ // Strip GTK3-style uppercase widget properties: -GtkWidget-*, -GtkDialog-*, etc.
226
+ if (/^-Gtk[A-Z]/.test(decl.prop)) {
227
+ decl.remove();
228
+ return;
229
+ }
230
+
231
+ if (!decl.prop.startsWith("-gtk-")) return;
232
+
233
+ switch (decl.prop) {
234
+ case "-gtk-icon-transform":
235
+ // -gtk-icon-transform → transform (just strip the -gtk- prefix)
236
+ decl.prop = "transform";
237
+ break;
238
+
239
+ case "-gtk-icon-shadow":
240
+ // -gtk-icon-shadow → filter: drop-shadow(...)
241
+ decl.prop = "filter";
242
+ decl.value = decl.value
243
+ .split(",")
244
+ .map((shadow) => `drop-shadow(${shadow.trim()})`)
245
+ .join(" ");
246
+ break;
247
+
248
+ case "-gtk-icon-size":
249
+ // Convert to a CSS custom property that components can read
250
+ decl.prop = "--gtk-icon-size";
251
+ break;
252
+
253
+ case "-gtk-icon-palette":
254
+ case "-gtk-icon-source":
255
+ case "-gtk-icon-style":
256
+ case "-gtk-icon-filter":
257
+ case "-gtk-icon-weight":
258
+ case "-gtk-dpi":
259
+ case "-gtk-secondary-caret-color":
260
+ // Remove — handled by React components or not applicable on web
261
+ decl.remove();
262
+ break;
263
+
264
+ default:
265
+ // Unknown -gtk- property — convert to CSS custom property as fallback
266
+ decl.prop = `--${decl.prop.slice(1)}`;
267
+ break;
268
+ }
269
+ },
270
+ };
271
+
272
+ /**
273
+ * PostCSS plugin: Replace CSS image() function with linear-gradient() fallback.
274
+ * image(color) → linear-gradient(color, color)
275
+ */
276
+ /**
277
+ * Extract the content of a balanced parenthesized expression starting at `start`.
278
+ * `start` should point to the opening `(`. Returns the content between parens
279
+ * and the index after the closing `)`.
280
+ */
281
+ function extractBalancedParens(
282
+ str: string,
283
+ start: number,
284
+ ): { content: string; end: number } | null {
285
+ if (str[start] !== "(") return null;
286
+ let depth = 0;
287
+ for (let i = start; i < str.length; i++) {
288
+ if (str[i] === "(") depth++;
289
+ if (str[i] === ")") depth--;
290
+ if (depth === 0) {
291
+ return { content: str.slice(start + 1, i), end: i + 1 };
292
+ }
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /**
298
+ * Split a string by commas, respecting nested parentheses.
299
+ * e.g. "rgba(0, 0, 0, 0.87), 0.15" → ["rgba(0, 0, 0, 0.87)", "0.15"]
300
+ */
301
+ function splitArgs(s: string): string[] {
302
+ const args: string[] = [];
303
+ let depth = 0;
304
+ let start = 0;
305
+ for (let i = 0; i < s.length; i++) {
306
+ if (s[i] === "(") depth++;
307
+ if (s[i] === ")") depth--;
308
+ if (s[i] === "," && depth === 0) {
309
+ args.push(s.slice(start, i).trim());
310
+ start = i + 1;
311
+ }
312
+ }
313
+ args.push(s.slice(start).trim());
314
+ return args;
315
+ }
316
+
317
+ /** Parse a hex color (#RGB, #RRGGBB, #RRGGBBAA) to [r, g, b] or null. */
318
+ function parseHex(hex: string): [number, number, number] | null {
319
+ const m = hex.match(/^#([0-9a-fA-F]+)$/);
320
+ if (!m) return null;
321
+ const h = m[1]!;
322
+ if (h.length === 3) {
323
+ return [parseInt(h[0]! + h[0]!, 16), parseInt(h[1]! + h[1]!, 16), parseInt(h[2]! + h[2]!, 16)];
324
+ }
325
+ if (h.length === 6 || h.length === 8) {
326
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
327
+ }
328
+ return null;
329
+ }
330
+
331
+ /** Named CSS colors used in GTK themes. */
332
+ const NAMED_COLORS: Record<string, [number, number, number]> = {
333
+ black: [0, 0, 0],
334
+ white: [255, 255, 255],
335
+ red: [255, 0, 0],
336
+ green: [0, 128, 0],
337
+ blue: [0, 0, 255],
338
+ transparent: [0, 0, 0],
339
+ };
340
+
341
+ /** Try to resolve a color string to RGB. Returns null for dynamic values. */
342
+ function resolveRGB(color: string): [number, number, number] | null {
343
+ const lower = color.toLowerCase();
344
+ if (NAMED_COLORS[lower]) return NAMED_COLORS[lower]!;
345
+ return parseHex(color);
346
+ }
347
+
348
+ /**
349
+ * Convert a GTK alpha(color, opacity) call to web CSS.
350
+ * - alpha(#hex, 0.5) → rgba(r, g, b, 0.5)
351
+ * - alpha(currentColor, 0.5) → color-mix(in srgb, currentColor 50%, transparent)
352
+ * - alpha(color) → color (single-arg form is identity in GTK)
353
+ */
354
+ function convertAlpha(content: string): string {
355
+ const args = splitArgs(content);
356
+ if (args.length === 1) return args[0]!; // single-arg: identity
357
+ if (args.length !== 2) {
358
+ throw new Error(`gtk-css: unexpected alpha() arguments: alpha(${content})`);
359
+ }
360
+
361
+ const color = args[0]!;
362
+ const opacity = args[1]!;
363
+ const opacityNum = parseFloat(opacity);
364
+
365
+ // Special case: named color "transparent"
366
+ if (color.toLowerCase() === "transparent") return "transparent";
367
+
368
+ const rgb = resolveRGB(color);
369
+ if (rgb) {
370
+ return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${opacity})`;
371
+ }
372
+
373
+ // Dynamic color (currentColor, var(), rgba(), etc.)
374
+ const pct = isNaN(opacityNum) ? opacity : `${Math.round(opacityNum * 100)}%`;
375
+ return `color-mix(in srgb, ${color} ${pct}, transparent)`;
376
+ }
377
+
378
+ /**
379
+ * Convert a GTK mix(color1, color2, factor) call to web CSS.
380
+ * GTK: mix(c1, c2, f) = f of c2 blended into c1.
381
+ * Web: color-mix(in srgb, c2 <f*100>%, c1)
382
+ */
383
+ function convertMix(content: string): string {
384
+ const args = splitArgs(content);
385
+ if (args.length !== 3) {
386
+ throw new Error(`gtk-css: unexpected mix() arguments: mix(${content})`);
387
+ }
388
+
389
+ const color1 = args[0]!;
390
+ const color2 = args[1]!;
391
+ const factor = args[2]!;
392
+
393
+ // Special case: mixing with transparent preserves transparency
394
+ if (color1.toLowerCase() === "transparent" && color2.toLowerCase() === "transparent") {
395
+ return "transparent";
396
+ }
397
+
398
+ const factorNum = parseFloat(factor);
399
+ const pct = isNaN(factorNum) ? factor : `${Math.round(factorNum * 100)}%`;
400
+ return `color-mix(in srgb, ${color2} ${pct}, ${color1})`;
401
+ }
402
+
403
+ /**
404
+ * Convert a GTK shade(color, factor) call to web CSS.
405
+ * factor > 1 = lighten (multiply RGB channels), factor < 1 = darken.
406
+ * For hex colors: pre-compute. For dynamic colors: approximate with color-mix.
407
+ */
408
+ function convertShade(content: string): string {
409
+ const args = splitArgs(content);
410
+ if (args.length !== 2) {
411
+ throw new Error(`gtk-css: unexpected shade() arguments: shade(${content})`);
412
+ }
413
+
414
+ const color = args[0]!;
415
+ const factor = parseFloat(args[1]!);
416
+ if (isNaN(factor)) {
417
+ throw new Error(`gtk-css: non-numeric shade() factor: shade(${content})`);
418
+ }
419
+
420
+ // Special case: transparent is rgba(0,0,0,0) — shading it still produces transparent
421
+ if (color.toLowerCase() === "transparent") return "transparent";
422
+
423
+ const rgb = resolveRGB(color);
424
+ if (rgb) {
425
+ // Pre-compute: multiply each channel by factor, clamp to 0-255
426
+ const r = Math.min(255, Math.max(0, Math.round(rgb[0] * factor)));
427
+ const g = Math.min(255, Math.max(0, Math.round(rgb[1] * factor)));
428
+ const b = Math.min(255, Math.max(0, Math.round(rgb[2] * factor)));
429
+ return `rgb(${r}, ${g}, ${b})`;
430
+ }
431
+
432
+ // Dynamic color: approximate with color-mix (uses srgb; fixColorMixGamut converts to oklab later)
433
+ if (factor >= 1) {
434
+ // Lighten: mix with white. shade(c, 1.2) ≈ 20% toward white
435
+ const pct = Math.round((1 - 1 / factor) * 100);
436
+ return `color-mix(in srgb, ${color}, white ${pct}%)`;
437
+ }
438
+ // Darken: mix with black. shade(c, 0.8) ≈ 20% toward black
439
+ const pct = Math.round((1 - factor) * 100);
440
+ return `color-mix(in srgb, ${color}, black ${pct}%)`;
441
+ }
442
+
443
+ /**
444
+ * Replace all occurrences of a GTK color function in a CSS value string.
445
+ * Uses balanced-paren matching so nested functions (e.g. alpha(rgba(...), 0.5)) work.
446
+ */
447
+ function replaceColorFunction(
448
+ val: string,
449
+ funcName: string,
450
+ converter: (content: string) => string,
451
+ ): string {
452
+ let result = "";
453
+ let i = 0;
454
+ const needle = funcName + "(";
455
+ const needleLen = needle.length;
456
+
457
+ while (i < val.length) {
458
+ const match = val.indexOf(needle, i);
459
+ if (match === -1) {
460
+ result += val.slice(i);
461
+ break;
462
+ }
463
+
464
+ // Guard: must not be preceded by a letter or hyphen (part of another function name)
465
+ if (match > 0 && /[a-z-]/i.test(val[match - 1]!)) {
466
+ result += val.slice(i, match + needleLen);
467
+ i = match + needleLen;
468
+ continue;
469
+ }
470
+
471
+ // Extract balanced parens
472
+ const parenStart = match + funcName.length;
473
+ const parens = extractBalancedParens(val, parenStart);
474
+ if (!parens) {
475
+ result += val.slice(i, match + needleLen);
476
+ i = match + needleLen;
477
+ continue;
478
+ }
479
+
480
+ result += val.slice(i, match);
481
+ result += converter(parens.content);
482
+ i = parens.end;
483
+ }
484
+
485
+ return result;
486
+ }
487
+
488
+ /**
489
+ * PostCSS plugin: Convert GTK-specific color functions to web CSS equivalents.
490
+ * - alpha(color, opacity) → rgba() or color-mix()
491
+ * - mix(color1, color2, factor) → color-mix()
492
+ * - shade(color, factor) → pre-computed rgb() or color-mix()
493
+ *
494
+ * Must run BEFORE fixColorMixGamut (which converts color-mix srgb→oklab).
495
+ */
496
+ const convertGtkColorFunctions: Plugin = {
497
+ postcssPlugin: "gtk-convert-color-functions",
498
+ Declaration(decl) {
499
+ const val = decl.value;
500
+ if (!val.includes("alpha(") && !val.includes("mix(") && !val.includes("shade(")) return;
501
+
502
+ let result = val;
503
+ if (result.includes("alpha(")) result = replaceColorFunction(result, "alpha", convertAlpha);
504
+ if (result.includes("shade(")) result = replaceColorFunction(result, "shade", convertShade);
505
+ if (result.includes("mix(")) result = replaceColorFunction(result, "mix", convertMix);
506
+
507
+ if (result !== val) {
508
+ decl.value = result;
509
+ }
510
+ },
511
+ };
512
+
513
+ const replaceImageFunction: Plugin = {
514
+ postcssPlugin: "gtk-replace-image-function",
515
+ Declaration(decl) {
516
+ if (!decl.value.includes("image(")) return;
517
+
518
+ // GTK's image(color) creates a solid-color image layer.
519
+ // Convert to linear-gradient(color, color) which is the web equivalent.
520
+ // This preserves layering behavior: background-image layers ON TOP of
521
+ // background-color, so hover overlays work correctly.
522
+ const result = replaceColorFunction(
523
+ decl.value,
524
+ "image",
525
+ (content) => `linear-gradient(${content}, ${content})`,
526
+ );
527
+ if (result !== decl.value) {
528
+ decl.value = result;
529
+ }
530
+ },
531
+ };
532
+
533
+ /**
534
+ * PostCSS plugin: Fix transition properties that reference -gtk-icon-transform.
535
+ */
536
+ const fixGtkTransitionRefs: Plugin = {
537
+ postcssPlugin: "gtk-fix-transition-refs",
538
+ Declaration(decl) {
539
+ if (decl.prop.startsWith("transition") && decl.value.includes("-gtk-icon-transform")) {
540
+ decl.value = decl.value.replace(/-gtk-icon-transform/g, "transform");
541
+ }
542
+ },
543
+ };
544
+
545
+ /**
546
+ * PostCSS plugin: Remove -gtk-recolor() function references.
547
+ */
548
+ const removeGtkRecolor: Plugin = {
549
+ postcssPlugin: "gtk-remove-recolor",
550
+ Declaration(decl) {
551
+ if (decl.value.includes("-gtk-recolor(") || decl.value.includes("-gtk-icontheme(")) {
552
+ decl.remove();
553
+ }
554
+ },
555
+ };
556
+
557
+ /**
558
+ * PostCSS plugin: Remove -gtk-scaled() declarations (not resolvable at runtime).
559
+ * Asset embedding is handled at build time in compile.ts via makeGtkAssetPlugin.
560
+ */
561
+ const removeGtkAssetFunctions: Plugin = {
562
+ postcssPlugin: "gtk-remove-asset-functions",
563
+ Declaration(decl) {
564
+ if (decl.value.includes("-gtk-scaled(")) {
565
+ decl.remove();
566
+ }
567
+ },
568
+ };
569
+
570
+ /**
571
+ * PostCSS plugin: Rewrite color-mix(in srgb, ...) to color-mix(in oklab, ...).
572
+ *
573
+ * GTK's CSS engine does NOT apply gamut mapping when converting colors to sRGB
574
+ * for color-mix interpolation:
575
+ * https://gitlab.gnome.org/GNOME/gtk/-/blob/5957885ec/gtk/gtkcsscolor.c#L659
576
+ * (FIXME comment: "Gamut mapping goes here")
577
+ *
578
+ * Browsers per CSS spec DO gamut-map, which clamps out-of-sRGB-gamut values
579
+ * (e.g. negative green from oklab-derived destructive colors). This produces
580
+ * visibly different results for wide-gamut colors like the destructive red.
581
+ *
582
+ * Switching the interpolation space to oklab avoids the issue: oklab values are
583
+ * always in-gamut, so no gamut mapping is triggered, matching GTK's behavior.
584
+ */
585
+ const COLOR_MIX_SRGB_RE = /color-mix\(in srgb\b/g;
586
+ const fixColorMixGamut: Plugin = {
587
+ postcssPlugin: "gtk-fix-color-mix-gamut",
588
+ Declaration(decl) {
589
+ if (decl.value.includes("color-mix(in srgb")) {
590
+ decl.value = decl.value.replace(COLOR_MIX_SRGB_RE, "color-mix(in oklab");
591
+ }
592
+ },
593
+ };
594
+
595
+ /**
596
+ * PostCSS plugin: Convert .gtk-switch padding to transparent border.
597
+ *
598
+ * In native GTK, padding on the switch reduces the child allocation — the slider
599
+ * knob is inset from the track edge. With CSS absolute positioning (used in
600
+ * layout.css), the containing block is the padding edge (inside border, outside
601
+ * padding), so padding does NOT shrink the space for absolutely-positioned children.
602
+ *
603
+ * Border DOES shrink it: the containing block for position:absolute is the
604
+ * padding edge, which is inside the border. So converting padding to transparent
605
+ * border achieves the same visual inset while correctly constraining the slider.
606
+ */
607
+ const fixSwitchPadding: Plugin = {
608
+ postcssPlugin: "gtk-fix-switch-padding",
609
+ Declaration(decl) {
610
+ if (decl.prop !== "padding") return;
611
+ if (!decl.parent || decl.parent.type !== "rule") return;
612
+ const rule = decl.parent;
613
+ if (!rule.selector.includes(".gtk-switch")) return;
614
+ if (rule.selector.includes(">")) return;
615
+
616
+ // Replace padding with transparent border of the same width
617
+ decl.prop = "border";
618
+ decl.value = `${decl.value} solid transparent`;
619
+ },
620
+ };
621
+
622
+ /**
623
+ * The complete GTK → web CSS PostCSS transformation pipeline.
624
+ * Pass a custom asset plugin to replace -gtk-scaled() with embedded data URIs.
625
+ */
626
+ export function buildGtkToWeb(assetPlugin?: Plugin) {
627
+ return postcss([
628
+ remapSelectors,
629
+ remapPseudoClasses,
630
+ handleGtkProperties,
631
+ fixGtkTransitionRefs,
632
+ convertGtkColorFunctions,
633
+ replaceImageFunction,
634
+ fixColorMixGamut,
635
+ removeGtkRecolor,
636
+ assetPlugin ?? removeGtkAssetFunctions,
637
+ fixSwitchPadding,
638
+ ]);
639
+ }
640
+
641
+ /** Default pipeline — removes -gtk-scaled() declarations. */
642
+ export const gtkToWeb = buildGtkToWeb();