@gjsify/adwaita-web 0.52.0 → 0.54.0
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 +34 -0
- package/dist/adwaita-web.css +4 -4
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/accent.d.ts +12 -2
- package/lib/types/adw-appearance.spec.d.ts +2 -0
- package/lib/types/appearance.d.ts +41 -0
- package/lib/types/font-stack.spec.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +8 -8
- package/scss/_variables.scss +44 -3
- package/src/accent.ts +28 -5
- package/src/adw-appearance.spec.ts +178 -0
- package/src/appearance.ts +207 -0
- package/src/blueprint-tree.spec.ts +1 -1
- package/src/elements/swipe-drag.ts +1 -1
- package/src/font-stack.spec.ts +110 -0
- package/src/index.ts +18 -0
- package/src/keyboard-operable.spec.ts +3 -3
- package/src/source-view/theme.ts +13 -1
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/adwaita-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Adwaita/Libadwaita web components for browser targets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
"@codemirror/language": "^6.12.4",
|
|
46
46
|
"@codemirror/state": "^6.7.0",
|
|
47
47
|
"@codemirror/view": "^6.43.4",
|
|
48
|
-
"@gjsify/adwaita-core": "^0.
|
|
49
|
-
"@gjsify/adwaita-fonts": "^0.
|
|
50
|
-
"@gjsify/adwaita-icons": "^0.
|
|
48
|
+
"@gjsify/adwaita-core": "^0.54.0",
|
|
49
|
+
"@gjsify/adwaita-fonts": "^0.54.0",
|
|
50
|
+
"@gjsify/adwaita-icons": "^0.54.0",
|
|
51
51
|
"@lezer/highlight": "^1.2.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/blueprint": "^0.
|
|
55
|
-
"@gjsify/cli": "^0.
|
|
56
|
-
"@gjsify/unit": "^0.
|
|
57
|
-
"@gjsify/vite-plugin-blueprint": "^0.
|
|
54
|
+
"@gjsify/blueprint": "^0.54.0",
|
|
55
|
+
"@gjsify/cli": "^0.54.0",
|
|
56
|
+
"@gjsify/unit": "^0.54.0",
|
|
57
|
+
"@gjsify/vite-plugin-blueprint": "^0.54.0",
|
|
58
58
|
"sass": "^1.101.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
},
|
package/scss/_variables.scss
CHANGED
|
@@ -118,8 +118,37 @@
|
|
|
118
118
|
--spacing-l: 18px;
|
|
119
119
|
--spacing-xl: 24px;
|
|
120
120
|
|
|
121
|
-
// Typography — GNOME default: Adwaita Sans 11
|
|
122
|
-
|
|
121
|
+
// Typography — GNOME default: Adwaita Sans 11. The stack is a PROGRESSION, and the
|
|
122
|
+
// tail is what makes it one (#1817). 'Adwaita Sans' is installed on a GNOME desktop
|
|
123
|
+
// and nowhere else; 'Cantarell' is GNOME's own earlier UI face and the right second
|
|
124
|
+
// guess there; 'Inter' is the one cross-platform name an app usually has beside it.
|
|
125
|
+
//
|
|
126
|
+
// What was MISSING is macOS, and it was missing in the worst place. With no Apple face
|
|
127
|
+
// named, `sans-serif` resolved to Helvetica — measured on a Mac carrying neither
|
|
128
|
+
// Adwaita Sans nor Cantarell — which is the one typeface no libadwaita application is
|
|
129
|
+
// meant to look like, and the stylesheet said nothing about it. `system-ui` and its
|
|
130
|
+
// `-apple-system` alias are the two spellings every current engine resolves to the
|
|
131
|
+
// platform's own UI face (SF on macOS, Segoe UI Variable on Windows, Cantarell on
|
|
132
|
+
// GNOME), so they go after the named faces and BEFORE the generic: a generic asks the
|
|
133
|
+
// host for its DEFAULT, which is the substitution this is about, while `system-ui` asks
|
|
134
|
+
// for its UI face, which is what an Adwaita app wants.
|
|
135
|
+
//
|
|
136
|
+
// 'Segoe UI' stays ahead of them as the EXPLICIT Windows face: `system-ui` reached it
|
|
137
|
+
// only from Windows 8 and Segoe UI Variable only from Windows 11, so the name is still
|
|
138
|
+
// the portable spelling there. Held honest by the `stylesheet-font-families`
|
|
139
|
+
// conformance rule, whose ledger carries the reason for 'Adwaita Sans' — never delete
|
|
140
|
+
// one without the other.
|
|
141
|
+
//
|
|
142
|
+
// THE PERIOD ending this first sentence, and the absence of a comment block between
|
|
143
|
+
// these declarations, are both load-bearing rather than stylistic.
|
|
144
|
+
// `scripts/generate-adwaita-tokens.mjs` titles each token group by the first sentence
|
|
145
|
+
// of the comment block touching its FIRST declaration, so a caption with no terminator
|
|
146
|
+
// runs on into the next words — and a block sitting between two declarations starts a
|
|
147
|
+
// NEW group, which is how a paragraph about this mechanism became a section title in
|
|
148
|
+
// the website before it was moved in here. Everything past the first sentence is prose
|
|
149
|
+
// for that one reader; `check-generated-website-data.mjs` holds both derived artifacts
|
|
150
|
+
// against the run.
|
|
151
|
+
--font-family: 'Adwaita Sans', 'Cantarell', 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
123
152
|
--font-size-base: 11pt;
|
|
124
153
|
--font-size-small: 9pt;
|
|
125
154
|
--font-size-heading: 12pt;
|
|
@@ -172,9 +201,21 @@
|
|
|
172
201
|
// GNOME's PROPORTIONAL UI font, so a monospace fallback chain that reaches it
|
|
173
202
|
// second lands on a font whose figures do not line up — the exact property
|
|
174
203
|
// `.monospace` exists for.
|
|
204
|
+
//
|
|
205
|
+
// The tail is the same PROGRESSION the sans stack is, for the same reason (#1817), and
|
|
206
|
+
// it had the gap in the other direction: `ui-monospace` covers macOS and GNOME but
|
|
207
|
+
// reaches Cascadia Mono only from Windows 10 1903, and no Windows face was named at
|
|
208
|
+
// all, so a Windows 10 host without it landed on the `monospace` GENERIC — Times New
|
|
209
|
+
// Roman, whose figures do not line up, which is the one thing this stack exists to
|
|
210
|
+
// provide. 'Cascadia Code' ships with Windows Terminal and 'Consolas' is on every
|
|
211
|
+
// Windows since Vista.
|
|
212
|
+
//
|
|
213
|
+
// ONE LINE, and not a style choice: the same generator reads this file with a line-based
|
|
214
|
+
// reader that REFUSES a declaration it cannot read rather than dropping the token
|
|
215
|
+
// without saying so, so a wrapped value makes it exit 1.
|
|
175
216
|
--document-font-family: var(--font-family);
|
|
176
217
|
--document-font-size: var(--font-size-base);
|
|
177
|
-
--monospace-font-family: 'Adwaita Mono', ui-monospace, 'SF Mono', 'Menlo', monospace;
|
|
218
|
+
--monospace-font-family: 'Adwaita Mono', ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace;
|
|
178
219
|
--monospace-font-size: var(--font-size-base);
|
|
179
220
|
}
|
|
180
221
|
|
package/src/accent.ts
CHANGED
|
@@ -58,17 +58,40 @@ export function isAdwaitaDark(element: HTMLElement): boolean {
|
|
|
58
58
|
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Elements whose accent the APP chose. The desktop-appearance follower
|
|
63
|
+
* (`appearance.ts`) never writes over one: an explicit choice outranks every
|
|
64
|
+
* source it reads (ADR 0078). A WeakSet, so a removed element is not kept alive.
|
|
65
|
+
*/
|
|
66
|
+
const appChosen = new WeakSet<HTMLElement>();
|
|
65
67
|
|
|
68
|
+
/** Whether the app has chosen `target`'s accent with {@link applyAdwaitaAccent}. */
|
|
69
|
+
export function hasAppChosenAccent(target: HTMLElement): boolean {
|
|
70
|
+
return appChosen.has(target);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Write the two properties — the one place they are set, for the app's choice and the follower's alike. */
|
|
74
|
+
export function writeAdwaitaAccent(name: AdwAccentColorName, target: HTMLElement, dark: boolean): void {
|
|
66
75
|
target.style.setProperty(ACCENT_BG_PROPERTY, adwaitaAccentBgColor(name));
|
|
67
76
|
target.style.setProperty(ACCENT_PROPERTY, adwaitaAccentColor(name, dark));
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Set the accent custom properties for `name` — the app's own choice, which
|
|
81
|
+
* from now on wins over the desktop's appearance on this element.
|
|
82
|
+
*/
|
|
83
|
+
export function applyAdwaitaAccent(name: AdwAccentColorName, options: ApplyAccentOptions = {}): void {
|
|
84
|
+
const target = options.target ?? document.documentElement;
|
|
85
|
+
appChosen.add(target);
|
|
86
|
+
writeAdwaitaAccent(name, target, options.dark ?? isAdwaitaDark(target));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Drop the properties, so the stylesheet's own values apply again, and hand
|
|
91
|
+
* the element back to the desktop's appearance.
|
|
92
|
+
*/
|
|
71
93
|
export function clearAdwaitaAccent(target: HTMLElement = document.documentElement): void {
|
|
94
|
+
appChosen.delete(target);
|
|
72
95
|
target.style.removeProperty(ACCENT_BG_PROPERTY);
|
|
73
96
|
target.style.removeProperty(ACCENT_PROPERTY);
|
|
74
97
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Following the desktop's appearance in a real browser (ADR 0078): the handoff
|
|
2
|
+
// tags, live JSON, the CSS system colour, and the precedence between them — all
|
|
3
|
+
// read back through the root's COMPUTED style, since the point is what reaches
|
|
4
|
+
// the cascade.
|
|
5
|
+
//
|
|
6
|
+
// Every test restores the root: the follower acts on `document.documentElement`,
|
|
7
|
+
// which every other suite in this bundle renders under.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
10
|
+
import { ADW_ACCENT_BG_COLORS, renderAppearanceMeta } from '@gjsify/adwaita-core';
|
|
11
|
+
import { APPEARANCE_HANDOFF_VECTORS, NEAREST_ACCENT_VECTORS } from '@gjsify/adwaita-core/conformance';
|
|
12
|
+
|
|
13
|
+
import '@gjsify/adwaita-web';
|
|
14
|
+
import { ACCENT_BG_PROPERTY, applyAdwaitaAccent, clearAdwaitaAccent } from './accent.js';
|
|
15
|
+
import { adwaitaAccentSource, applyDesktopAppearance, applySystemAccent, readSystemAccent } from './appearance.js';
|
|
16
|
+
|
|
17
|
+
const root = () => document.documentElement;
|
|
18
|
+
const rootAccent = () => getComputedStyle(root()).getPropertyValue(ACCENT_BG_PROPERTY).trim();
|
|
19
|
+
|
|
20
|
+
/** MutationObserver callbacks are microtasks; one macrotask later they have run. */
|
|
21
|
+
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
22
|
+
|
|
23
|
+
function addMeta(name: string, content: string): HTMLMetaElement {
|
|
24
|
+
const meta = document.createElement('meta');
|
|
25
|
+
meta.name = name;
|
|
26
|
+
meta.content = content;
|
|
27
|
+
document.head.append(meta);
|
|
28
|
+
return meta;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function restore(...metas: HTMLMetaElement[]): Promise<void> {
|
|
32
|
+
for (const meta of metas) meta.remove();
|
|
33
|
+
await settle();
|
|
34
|
+
applySystemAccent(false);
|
|
35
|
+
applyDesktopAppearance({});
|
|
36
|
+
clearAdwaitaAccent(root());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const AdwAppearanceTest = async () => {
|
|
40
|
+
await describe('desktop appearance: the <meta> handoff', async () => {
|
|
41
|
+
await it('applies a handed-over accent without any code in the page', async () => {
|
|
42
|
+
const defaultAccent = rootAccent();
|
|
43
|
+
const meta = addMeta('adw-accent', 'purple');
|
|
44
|
+
await settle();
|
|
45
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.purple);
|
|
46
|
+
expect(adwaitaAccentSource()).toBe('handoff');
|
|
47
|
+
|
|
48
|
+
// Removing the tag hands the root back to the stylesheet.
|
|
49
|
+
meta.remove();
|
|
50
|
+
await settle();
|
|
51
|
+
expect(rootAccent()).toBe(defaultAccent);
|
|
52
|
+
expect(adwaitaAccentSource()).toBe('default');
|
|
53
|
+
await restore();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await it('follows a changed content attribute', async () => {
|
|
57
|
+
const meta = addMeta('adw-accent', 'teal');
|
|
58
|
+
await settle();
|
|
59
|
+
meta.content = 'orange';
|
|
60
|
+
await settle();
|
|
61
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.orange);
|
|
62
|
+
await restore(meta);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await it('hands over the colour scheme as a theme class, and takes it back', async () => {
|
|
66
|
+
const meta = addMeta('adw-color-scheme', 'dark');
|
|
67
|
+
await settle();
|
|
68
|
+
expect(root().classList.contains('theme-dark')).toBe(true);
|
|
69
|
+
meta.content = 'no-preference';
|
|
70
|
+
await settle();
|
|
71
|
+
expect(root().classList.contains('theme-dark')).toBe(false);
|
|
72
|
+
await restore(meta);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await it('does not re-read the tags for an unrelated <head> change', async () => {
|
|
76
|
+
// A live JSON handoff must survive a framework injecting a <style>.
|
|
77
|
+
applyDesktopAppearance({ accent: 'green' });
|
|
78
|
+
document.head.append(document.createElement('style'));
|
|
79
|
+
await settle();
|
|
80
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.green);
|
|
81
|
+
document.head.lastElementChild?.remove();
|
|
82
|
+
await restore();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await describe('desktop appearance: APPEARANCE_HANDOFF_VECTORS through a real <head>', async () => {
|
|
87
|
+
for (const vector of APPEARANCE_HANDOFF_VECTORS) {
|
|
88
|
+
await it(`${JSON.stringify(vector.meta)} paints ${vector.paints ?? 'the default'}`, async () => {
|
|
89
|
+
const defaultAccent = rootAccent();
|
|
90
|
+
const holder = document.createElement('template');
|
|
91
|
+
holder.innerHTML = renderAppearanceMeta(vector.appearance);
|
|
92
|
+
const metas = [...holder.content.children] as HTMLMetaElement[];
|
|
93
|
+
document.head.append(...metas);
|
|
94
|
+
await settle();
|
|
95
|
+
expect(rootAccent()).toBe(vector.paints ? ADW_ACCENT_BG_COLORS[vector.paints] : defaultAccent);
|
|
96
|
+
const themeClass = root().classList.contains('theme-dark')
|
|
97
|
+
? 'theme-dark'
|
|
98
|
+
: root().classList.contains('theme-light')
|
|
99
|
+
? 'theme-light'
|
|
100
|
+
: null;
|
|
101
|
+
expect(themeClass).toBe(vector.themeClass);
|
|
102
|
+
await restore(...metas);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await describe('desktop appearance: a colour-only handoff is snapped (NEAREST_ACCENT_VECTORS)', async () => {
|
|
108
|
+
// A server that sends only `accentRgb` gets libadwaita's snapping in the page, read
|
|
109
|
+
// back from the cascade: the table is held by what the page paints, not by the function.
|
|
110
|
+
for (const vector of NEAREST_ACCENT_VECTORS) {
|
|
111
|
+
await it(`${vector.source}: ${vector.color} paints ${vector.expected}`, async () => {
|
|
112
|
+
applyDesktopAppearance({ accentRgb: vector.color });
|
|
113
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS[vector.expected]);
|
|
114
|
+
await restore();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await describe('desktop appearance: live JSON and precedence', async () => {
|
|
120
|
+
await it('applies validated JSON and ignores what is not an accent', async () => {
|
|
121
|
+
applyDesktopAppearance({ accent: 'red', colorScheme: 'light' });
|
|
122
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.red);
|
|
123
|
+
applyDesktopAppearance({ accent: '"><script>' });
|
|
124
|
+
expect(adwaitaAccentSource()).toBe('default');
|
|
125
|
+
await restore();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await it("never overrides the app's own choice", async () => {
|
|
129
|
+
applyAdwaitaAccent('yellow');
|
|
130
|
+
applyDesktopAppearance({ accent: 'pink' });
|
|
131
|
+
const meta = addMeta('adw-accent', 'slate');
|
|
132
|
+
await settle();
|
|
133
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.yellow);
|
|
134
|
+
expect(adwaitaAccentSource()).toBe('app');
|
|
135
|
+
|
|
136
|
+
// Clearing the app's choice hands the root back to the desktop.
|
|
137
|
+
clearAdwaitaAccent(root());
|
|
138
|
+
applyDesktopAppearance({ accent: 'pink' });
|
|
139
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.pink);
|
|
140
|
+
await restore(meta);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await it('ranks the handoff above the CSS system colour', async () => {
|
|
144
|
+
applySystemAccent();
|
|
145
|
+
applyDesktopAppearance({ accent: 'teal' });
|
|
146
|
+
expect(adwaitaAccentSource()).toBe('handoff');
|
|
147
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS.teal);
|
|
148
|
+
await restore();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await describe('desktop appearance: the CSS system colour (#1821)', async () => {
|
|
153
|
+
await it('snaps AccentColor to one of the nine where the engine resolves it, else keeps blue', async () => {
|
|
154
|
+
// Both outcomes are asserted: support for `AccentColor` differs per engine and
|
|
155
|
+
// per OS, and "unsupported" must leave the stylesheet's blue, not a guess.
|
|
156
|
+
const defaultAccent = rootAccent();
|
|
157
|
+
const system = readSystemAccent();
|
|
158
|
+
const applied = applySystemAccent();
|
|
159
|
+
expect(applied).toBe(system?.accent ?? null);
|
|
160
|
+
if (system) {
|
|
161
|
+
expect(adwaitaAccentSource()).toBe('system');
|
|
162
|
+
expect(rootAccent()).toBe(ADW_ACCENT_BG_COLORS[system.accent]);
|
|
163
|
+
} else {
|
|
164
|
+
expect(adwaitaAccentSource()).toBe('default');
|
|
165
|
+
expect(rootAccent()).toBe(defaultAccent);
|
|
166
|
+
}
|
|
167
|
+
await restore();
|
|
168
|
+
expect(rootAccent()).toBe(defaultAccent);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await it('the meta keyword "system" asks for the same thing', async () => {
|
|
172
|
+
const meta = addMeta('adw-accent', 'system');
|
|
173
|
+
await settle();
|
|
174
|
+
expect(adwaitaAccentSource()).toBe(readSystemAccent() ? 'system' : 'default');
|
|
175
|
+
await restore(meta);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Following the desktop's appearance in a plain web page (ADR 0078, #1821).
|
|
2
|
+
//
|
|
3
|
+
// A page learns the desktop's accent from, in order of precedence:
|
|
4
|
+
//
|
|
5
|
+
// 1. the APP's own choice `applyAdwaitaAccent(name)` — never overwritten here
|
|
6
|
+
// 2. a server HANDOFF `<meta name="adw-accent" content="purple">`, or
|
|
7
|
+
// `applyDesktopAppearance(json)` for live updates
|
|
8
|
+
// 3. the CSS SYSTEM COLOUR `AccentColor`, once asked for (`applySystemAccent()`
|
|
9
|
+
// or `<meta name="adw-accent" content="system">`)
|
|
10
|
+
// 4. Adwaita blue the stylesheet's own value
|
|
11
|
+
//
|
|
12
|
+
// The handoff outranks `AccentColor` because it is the better-informed source:
|
|
13
|
+
// a gjsify server reads the desktop itself (`@gjsify/adwaita-app/appearance`),
|
|
14
|
+
// while `AccentColor` is supported unevenly across engines and a page cannot
|
|
15
|
+
// tell a real system accent from an engine's fixed default.
|
|
16
|
+
//
|
|
17
|
+
// EVERY SOURCE IS SNAPPED TO ONE OF THE NINE with `nearestAccent`, as libadwaita
|
|
18
|
+
// does with the system colour it reads, so a web page and a native Adwaita
|
|
19
|
+
// window on the same desktop show the same accent. Consequently the standalone
|
|
20
|
+
// `--accent-color` is libadwaita's derivation of a palette colour, never of the
|
|
21
|
+
// raw system colour.
|
|
22
|
+
//
|
|
23
|
+
// Only `document.documentElement` follows; an accent set on any other element
|
|
24
|
+
// is the app's business.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
ADW_ACCENT_META_SYSTEM,
|
|
28
|
+
type AdwAccentColorName,
|
|
29
|
+
type AppearanceMeta,
|
|
30
|
+
appearanceFromMeta,
|
|
31
|
+
type DesktopAppearance,
|
|
32
|
+
formatAdwRgb,
|
|
33
|
+
nearestAccent,
|
|
34
|
+
parseAdwRgb,
|
|
35
|
+
parseDesktopAppearance,
|
|
36
|
+
} from '@gjsify/adwaita-core';
|
|
37
|
+
|
|
38
|
+
import { clearAdwaitaAccent, hasAppChosenAccent, isAdwaitaDark, writeAdwaitaAccent } from './accent.js';
|
|
39
|
+
|
|
40
|
+
/** Which source the root's accent currently comes from. */
|
|
41
|
+
export type AdwAccentSource = 'app' | 'handoff' | 'system' | 'default';
|
|
42
|
+
|
|
43
|
+
/** What the handoff said last: from the `<meta>` tags, or from `applyDesktopAppearance`. */
|
|
44
|
+
let handoff: AppearanceMeta & { accentRgb?: string } = {};
|
|
45
|
+
/** Whether the page asked to follow CSS `AccentColor`. */
|
|
46
|
+
let systemRequested = false;
|
|
47
|
+
/** Whether the follower has written the root's accent, so it knows what it may remove. */
|
|
48
|
+
let wroteAccent = false;
|
|
49
|
+
/** The theme class the follower added for a handed-off colour scheme, if any. */
|
|
50
|
+
let addedSchemeClass: 'theme-dark' | 'theme-light' | null = null;
|
|
51
|
+
let installed = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The browser's CSS system colour `AccentColor`, snapped to the nearest of the
|
|
55
|
+
* nine — or `null` where the engine does not support the keyword or the
|
|
56
|
+
* resolved colour cannot be read.
|
|
57
|
+
*
|
|
58
|
+
* SUPPORT IS UNEVEN AND NOT ASSUMED: Chrome announced shipping `AccentColor`
|
|
59
|
+
* only in 2026, and what Firefox resolves it to on Linux is not established;
|
|
60
|
+
* Firefox 156 on macOS resolves it to `rgb(0, 122, 255)`. An engine may also
|
|
61
|
+
* report a fixed default instead of the real accent (fingerprinting
|
|
62
|
+
* protection), which a page cannot detect.
|
|
63
|
+
*/
|
|
64
|
+
export function readSystemAccent(): { accent: AdwAccentColorName; accentRgb: string } | null {
|
|
65
|
+
if (typeof CSS === 'undefined' || !CSS.supports('color', 'AccentColor')) return null;
|
|
66
|
+
const probe = document.createElement('span');
|
|
67
|
+
probe.style.color = 'AccentColor';
|
|
68
|
+
probe.style.display = 'none';
|
|
69
|
+
document.documentElement.append(probe);
|
|
70
|
+
const resolved = getComputedStyle(probe).color;
|
|
71
|
+
probe.remove();
|
|
72
|
+
const rgb = parseAdwRgb(resolved);
|
|
73
|
+
if (!rgb) return null;
|
|
74
|
+
return { accent: nearestAccent(rgb), accentRgb: formatAdwRgb(rgb) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolve(): { accent: AdwAccentColorName; source: AdwAccentSource } | null {
|
|
78
|
+
if (handoff.accent && handoff.accent !== ADW_ACCENT_META_SYSTEM)
|
|
79
|
+
return { accent: handoff.accent, source: 'handoff' };
|
|
80
|
+
// A handoff that carries only a colour (a server that did not snap it) is snapped here.
|
|
81
|
+
const handedRgb = handoff.accentRgb ? parseAdwRgb(handoff.accentRgb) : null;
|
|
82
|
+
if (handedRgb) return { accent: nearestAccent(handedRgb), source: 'handoff' };
|
|
83
|
+
if (systemRequested || handoff.accent === ADW_ACCENT_META_SYSTEM) {
|
|
84
|
+
const system = readSystemAccent();
|
|
85
|
+
if (system) return { accent: system.accent, source: 'system' };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The source the root's accent comes from right now. */
|
|
91
|
+
export function adwaitaAccentSource(): AdwAccentSource {
|
|
92
|
+
if (typeof document === 'undefined') return 'default';
|
|
93
|
+
if (hasAppChosenAccent(document.documentElement)) return 'app';
|
|
94
|
+
return resolve()?.source ?? 'default';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function syncScheme(root: HTMLElement): void {
|
|
98
|
+
const scheme = handoff.colorScheme;
|
|
99
|
+
const wanted = scheme === 'dark' ? 'theme-dark' : scheme === 'light' ? 'theme-light' : null;
|
|
100
|
+
if (wanted === addedSchemeClass) return;
|
|
101
|
+
if (addedSchemeClass) root.classList.remove(addedSchemeClass);
|
|
102
|
+
addedSchemeClass = null;
|
|
103
|
+
// A theme class the APP put on the root is its choice; the handoff does not fight it.
|
|
104
|
+
if (wanted && !root.classList.contains('theme-dark') && !root.classList.contains('theme-light')) {
|
|
105
|
+
root.classList.add(wanted);
|
|
106
|
+
addedSchemeClass = wanted;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Re-apply every automatic source to the root, in precedence order. */
|
|
111
|
+
function sync(): void {
|
|
112
|
+
const root = document.documentElement;
|
|
113
|
+
syncScheme(root);
|
|
114
|
+
if (hasAppChosenAccent(root)) return;
|
|
115
|
+
const resolved = resolve();
|
|
116
|
+
if (resolved) {
|
|
117
|
+
writeAdwaitaAccent(resolved.accent, root, isAdwaitaDark(root));
|
|
118
|
+
wroteAccent = true;
|
|
119
|
+
} else if (wroteAccent) {
|
|
120
|
+
clearAdwaitaAccent(root);
|
|
121
|
+
wroteAccent = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isHandoffMeta = (node: Node): boolean =>
|
|
126
|
+
node.nodeName === 'META' && (node as Element).getAttribute('name')?.startsWith('adw-') === true;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Only a change to one of OUR tags re-reads them. Frameworks and this package
|
|
130
|
+
* itself insert <style> and <script> into <head> all the time, and re-reading on
|
|
131
|
+
* those would reset a live handoff from `applyDesktopAppearance` to the tags.
|
|
132
|
+
*/
|
|
133
|
+
function touchesHandoffMeta(record: MutationRecord): boolean {
|
|
134
|
+
// A renamed tag counts by its old name too, so dropping `name="adw-accent"` is seen.
|
|
135
|
+
if (record.type === 'attributes')
|
|
136
|
+
return (
|
|
137
|
+
isHandoffMeta(record.target) ||
|
|
138
|
+
(record.attributeName === 'name' && record.oldValue?.startsWith('adw-') === true)
|
|
139
|
+
);
|
|
140
|
+
return [...record.addedNodes, ...record.removedNodes].some(isHandoffMeta);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readMetaHandoff(): void {
|
|
144
|
+
handoff = appearanceFromMeta(
|
|
145
|
+
(name) => document.head.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? null,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Start following: read the handoff tags, and re-apply when they change, when
|
|
151
|
+
* the colour scheme flips (the standalone accent differs between light and
|
|
152
|
+
* dark) and when the page regains focus (`AccentColor` changes with no event).
|
|
153
|
+
* Idempotent; `@gjsify/adwaita-web` calls it on import in a browser.
|
|
154
|
+
*/
|
|
155
|
+
export function followDesktopAppearance(): void {
|
|
156
|
+
if (installed || typeof document === 'undefined') return;
|
|
157
|
+
installed = true;
|
|
158
|
+
readMetaHandoff();
|
|
159
|
+
sync();
|
|
160
|
+
new MutationObserver((records) => {
|
|
161
|
+
if (records.some(touchesHandoffMeta)) {
|
|
162
|
+
readMetaHandoff();
|
|
163
|
+
sync();
|
|
164
|
+
}
|
|
165
|
+
}).observe(document.head, {
|
|
166
|
+
subtree: true,
|
|
167
|
+
childList: true,
|
|
168
|
+
attributes: true,
|
|
169
|
+
attributeFilter: ['content', 'name'],
|
|
170
|
+
attributeOldValue: true,
|
|
171
|
+
});
|
|
172
|
+
globalThis.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', sync);
|
|
173
|
+
globalThis.addEventListener('focus', () => {
|
|
174
|
+
if (systemRequested || handoff.accent === ADW_ACCENT_META_SYSTEM) sync();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Follow the CSS system colour `AccentColor` (#1821); `false` stops following
|
|
180
|
+
* it. A server handoff and the app's own choice still win; where the engine
|
|
181
|
+
* cannot resolve `AccentColor` the page keeps Adwaita blue. Returns the accent
|
|
182
|
+
* the system colour snapped to, or `null` (always when disabling).
|
|
183
|
+
*/
|
|
184
|
+
export function applySystemAccent(enabled = true): AdwAccentColorName | null {
|
|
185
|
+
followDesktopAppearance();
|
|
186
|
+
systemRequested = enabled;
|
|
187
|
+
sync();
|
|
188
|
+
return enabled ? (readSystemAccent()?.accent ?? null) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Apply a desktop appearance handed over at runtime — the JSON a gjsify server
|
|
193
|
+
* sends when the desktop changes (over SSE, a WebSocket, a message port). The
|
|
194
|
+
* value is validated, so an untrusted payload is safe to pass straight in; it
|
|
195
|
+
* REPLACES the previous handoff, including one from the `<meta>` tags.
|
|
196
|
+
*/
|
|
197
|
+
export function applyDesktopAppearance(appearance: unknown): void {
|
|
198
|
+
// Install first: installing reads the <meta> tags, which must not overwrite this newer value.
|
|
199
|
+
followDesktopAppearance();
|
|
200
|
+
const { accent, accentRgb, colorScheme }: DesktopAppearance = parseDesktopAppearance(appearance);
|
|
201
|
+
handoff = {
|
|
202
|
+
...(accent ? { accent } : {}),
|
|
203
|
+
...(accentRgb ? { accentRgb } : {}),
|
|
204
|
+
...(colorScheme ? { colorScheme } : {}),
|
|
205
|
+
};
|
|
206
|
+
sync();
|
|
207
|
+
}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// `title-widget:` are GtkBuilder's names, this renderer's are the unnamed slot, `top` and
|
|
34
34
|
// `center`, and a table mapping one to the other inside one renderer is the per-surface
|
|
35
35
|
// translator ADR 0051 § Alternatives rejected turned down on a measurement. Tracked in
|
|
36
|
-
// `status/open-todos.md`.
|
|
36
|
+
// `status/open-todos/README.md`.
|
|
37
37
|
|
|
38
38
|
import { describe, expect, it } from '@gjsify/unit';
|
|
39
39
|
|
|
@@ -179,7 +179,7 @@ export function attachSwipeDrag(init: AdwSwipeDragInit): AdwSwipeDrag {
|
|
|
179
179
|
* at ALL yet: its offset model is `scrollLeft = position * distance`, and an RTL
|
|
180
180
|
* scroll container counts `scrollLeft` DOWN from 0, so even `scrollToPage(1)` moves
|
|
181
181
|
* nothing (measured). A reversed branch would compute a correct progress and write it
|
|
182
|
-
* to a container that ignores it. `status/open-todos.md` carries the whole gap; the
|
|
182
|
+
* to a container that ignores it. `status/open-todos/README.md` carries the whole gap; the
|
|
183
183
|
* flag belongs in the change that can test it.
|
|
184
184
|
*/
|
|
185
185
|
const progressOffset = (dx: number, dy: number) => -axisOffset(dx, dy);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The fallback TAIL of both font stacks — the part that decides what a widget set
|
|
2
|
+
// looks like on a host that has neither Adwaita face.
|
|
3
|
+
//
|
|
4
|
+
// THE INCIDENT (#1817)
|
|
5
|
+
//
|
|
6
|
+
// `--font-family` was `'Adwaita Sans', 'Cantarell', 'Inter', 'Segoe UI', sans-serif`
|
|
7
|
+
// and `--monospace-font-family` was `'Adwaita Mono', ui-monospace, 'SF Mono',
|
|
8
|
+
// 'Menlo', monospace`. Both are correct on a GNOME desktop and wrong everywhere
|
|
9
|
+
// the Adwaita faces are absent, which is everywhere else: neither package ships
|
|
10
|
+
// them (see `status/stylesheet-font-families.json`), so a consumer that does not
|
|
11
|
+
// call `applyAdwaitaFonts()` reaches the generic. `sans-serif` is Helvetica on
|
|
12
|
+
// macOS — the font whose metrics every other platform has moved away from — and
|
|
13
|
+
// the monospace generic is Courier New on Windows. Nothing errored; the UI just
|
|
14
|
+
// looked borrowed. Found from a consumer's screenshot, which is the only place
|
|
15
|
+
// this is visible at all.
|
|
16
|
+
//
|
|
17
|
+
// READS THE DECLARATION, NOT A COMPUTED FONT
|
|
18
|
+
//
|
|
19
|
+
// This asserts the SHAPE OF A STACK, not that any family is installed — the
|
|
20
|
+
// distinction `adw-fonts.spec.ts` refuses to blur. So it reads the custom
|
|
21
|
+
// property off the `:root` rule of the injected `<style>`, which is the authored
|
|
22
|
+
// text and byte-identical on every host, rather than
|
|
23
|
+
// `getComputedStyle(el).fontFamily`, which resolves against the machine's font
|
|
24
|
+
// map. The second would pass or fail depending on which fonts the runner has,
|
|
25
|
+
// which is an assertion about the CI image wearing a test's clothes.
|
|
26
|
+
//
|
|
27
|
+
// The ORDER is asserted, not just membership. `system-ui` after the generic
|
|
28
|
+
// family is a stack that is textually present and does nothing, and so is
|
|
29
|
+
// `-apple-system` ahead of a `'Segoe UI'` that a Windows host never reaches.
|
|
30
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
31
|
+
|
|
32
|
+
import '@gjsify/adwaita-web';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The declared value of a custom property, read off the stylesheet's own rules.
|
|
36
|
+
*
|
|
37
|
+
* NOT `getComputedStyle`, and not "the first `:root`": the sheet declares `:root`
|
|
38
|
+
* more than once — the generated icon block is one, the light-theme token block is
|
|
39
|
+
* another — and only some of them carry a given token. So every `:root` rule is
|
|
40
|
+
* asked and the first that DECLARES the property answers. The assertion is about
|
|
41
|
+
* the authored text, which is the same on every host.
|
|
42
|
+
*/
|
|
43
|
+
const declared = (property: string): string => {
|
|
44
|
+
const sheet = (document.getElementById('adwaita-web-style') as HTMLStyleElement | null)?.sheet;
|
|
45
|
+
expect(sheet).not.toBeNull();
|
|
46
|
+
for (let index = 0; index < (sheet as CSSStyleSheet).cssRules.length; index++) {
|
|
47
|
+
const rule = (sheet as CSSStyleSheet).cssRules[index];
|
|
48
|
+
if (!(rule instanceof CSSStyleRule) || rule.selectorText !== ':root') continue;
|
|
49
|
+
const value = rule.style.getPropertyValue(property).trim();
|
|
50
|
+
if (value.length > 0) return value;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`no :root rule in #adwaita-web-style declares ${property}`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The stack's family names, in order, unquoted — a family list split on commas. */
|
|
56
|
+
const families = (value: string): string[] =>
|
|
57
|
+
value
|
|
58
|
+
.split(',')
|
|
59
|
+
.map((family) => family.trim().replace(/^['"]|['"]$/g, ''))
|
|
60
|
+
.filter((family) => family.length > 0);
|
|
61
|
+
|
|
62
|
+
/** Index of `family` in the declared stack, or `-1`. */
|
|
63
|
+
const at = (stack: string[], family: string): number => stack.indexOf(family);
|
|
64
|
+
|
|
65
|
+
export const AdwFontStackTest = async () => {
|
|
66
|
+
await describe('the font fallback tail', async () => {
|
|
67
|
+
await it('names a native face for macOS and Windows before the generic', async () => {
|
|
68
|
+
const stack = families(declared('--font-family'));
|
|
69
|
+
|
|
70
|
+
// Every OS's own UI face is reachable, and the generic is LAST — the
|
|
71
|
+
// whole defect was a stack that ended too early for two of the three
|
|
72
|
+
// desktop platforms.
|
|
73
|
+
expect(stack).toContain('system-ui');
|
|
74
|
+
expect(stack).toContain('-apple-system');
|
|
75
|
+
expect(stack).toContain('Segoe UI');
|
|
76
|
+
expect(at(stack, 'system-ui')).toBeGreaterThan(-1);
|
|
77
|
+
expect(at(stack, 'system-ui')).toBeLessThan(stack.length - 1);
|
|
78
|
+
expect(stack[stack.length - 1]).toBe('sans-serif');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await it('keeps the Adwaita family first, so a GNOME host is unaffected', async () => {
|
|
82
|
+
// The tail was the defect; moving the HEAD would be a different one. On a
|
|
83
|
+
// host that has the face, `system-ui` resolves to the same place and the
|
|
84
|
+
// declaration must not have changed which family wins.
|
|
85
|
+
const stack = families(declared('--font-family'));
|
|
86
|
+
expect(stack[0]).toBe('Adwaita Sans');
|
|
87
|
+
expect(at(stack, 'Adwaita Sans')).toBeLessThan(at(stack, 'system-ui'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await it('names Consolas for Windows, where ui-monospace is not supported', async () => {
|
|
91
|
+
// `ui-monospace` is the right way to ask and is NOT universally supported;
|
|
92
|
+
// the engines without it fell straight through to the `monospace` generic,
|
|
93
|
+
// which is Courier New on Windows. Same repair as the sans stack: the
|
|
94
|
+
// platform's own face, named, before the generic.
|
|
95
|
+
const stack = families(declared('--monospace-font-family'));
|
|
96
|
+
expect(stack).toContain('Consolas');
|
|
97
|
+
expect(at(stack, 'Consolas')).toBeLessThan(stack.length - 1);
|
|
98
|
+
expect(stack[stack.length - 1]).toBe('monospace');
|
|
99
|
+
expect(stack[0]).toBe('Adwaita Mono');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await it('carries the repaired tail into the document stack too', async () => {
|
|
103
|
+
// `.document` text reads `--document-font-family`, which is a `var()` over
|
|
104
|
+
// the base token rather than a stack of its own — so the repair reaches it
|
|
105
|
+
// for free ONLY while it stays a reference. Written out longhand it would
|
|
106
|
+
// be a fourth place to forget, which is the failure this pins.
|
|
107
|
+
expect(declared('--document-font-family')).toBe('var(--font-family)');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
};
|