@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 +15 -0
- package/package.json +17 -0
- package/src/compile.ts +84 -0
- package/src/index.ts +3 -0
- package/src/selectors.ts +203 -0
- package/src/theme.ts +23 -0
- package/src/transform.test.ts +172 -0
- package/src/transform.ts +642 -0
package/CHANGELOG.md
ADDED
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
package/src/selectors.ts
ADDED
|
@@ -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
|
+
});
|
package/src/transform.ts
ADDED
|
@@ -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();
|