@gjsify/adwaita-web 0.52.0 → 0.53.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 +22 -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 +7 -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.53.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.53.0",
|
|
49
|
+
"@gjsify/adwaita-fonts": "^0.53.0",
|
|
50
|
+
"@gjsify/adwaita-icons": "^0.53.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.53.0",
|
|
55
|
+
"@gjsify/cli": "^0.53.0",
|
|
56
|
+
"@gjsify/unit": "^0.53.0",
|
|
57
|
+
"@gjsify/vite-plugin-blueprint": "^0.53.0",
|
|
58
58
|
"sass": "^1.101.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
},
|
package/scss/_variables.scss
CHANGED
|
@@ -118,8 +118,21 @@
|
|
|
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 tail is a PLATFORM-NATIVE
|
|
122
|
+
// ladder, and it is there because the families above it resolve on a GNOME desktop
|
|
123
|
+
// and NOWHERE ELSE: 'Adwaita Sans' is NAMED, not shipped (`status/stylesheet-font-
|
|
124
|
+
// families.json`), and 'Cantarell' is its predecessor. A macOS host has neither,
|
|
125
|
+
// and the `sans-serif` generic is Helvetica there — #1817, a widget set that looks
|
|
126
|
+
// right on the machine it was authored on and lands on Helvetica in every screenshot
|
|
127
|
+
// a consumer takes. `system-ui` asks the host for its own UI face (San Francisco,
|
|
128
|
+
// Segoe UI); `-apple-system` and 'Segoe UI' are the pre-`system-ui` spellings of
|
|
129
|
+
// those same two answers, kept for the engines that need them.
|
|
130
|
+
//
|
|
131
|
+
// The caption is this block's FIRST SENTENCE on purpose: `generate-adwaita-tokens.mjs`
|
|
132
|
+
// titles a group from the block touching its declaration, cut at the first `. `, so a
|
|
133
|
+
// caption written as a trailing line is unreachable and an uncapped one becomes a
|
|
134
|
+
// section header on the website's theming page.
|
|
135
|
+
--font-family: 'Adwaita Sans', 'Cantarell', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
123
136
|
--font-size-base: 11pt;
|
|
124
137
|
--font-size-small: 9pt;
|
|
125
138
|
--font-size-heading: 12pt;
|
|
@@ -172,9 +185,15 @@
|
|
|
172
185
|
// GNOME's PROPORTIONAL UI font, so a monospace fallback chain that reaches it
|
|
173
186
|
// second lands on a font whose figures do not line up — the exact property
|
|
174
187
|
// `.monospace` exists for.
|
|
188
|
+
//
|
|
189
|
+
// 'Consolas' is the same repair as `system-ui` beside it in `--font-family` (#1817):
|
|
190
|
+
// `ui-monospace` is the right way to ask and is not universally supported, and the
|
|
191
|
+
// engines without it fell to the `monospace` generic — Courier New on Windows. The
|
|
192
|
+
// `'SF Mono'` / `'Menlo'` pair answers on macOS and `ui-monospace` on Linux, so this
|
|
193
|
+
// is the Windows half, not a second opinion about the other two.
|
|
175
194
|
--document-font-family: var(--font-family);
|
|
176
195
|
--document-font-size: var(--font-size-base);
|
|
177
|
-
--monospace-font-family: 'Adwaita Mono', ui-monospace, 'SF Mono', 'Menlo', monospace;
|
|
196
|
+
--monospace-font-family: 'Adwaita Mono', ui-monospace, 'SF Mono', 'Menlo', 'Consolas', monospace;
|
|
178
197
|
--monospace-font-size: var(--font-size-base);
|
|
179
198
|
}
|
|
180
199
|
|
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
|
+
};
|