@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.52.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.52.0",
49
- "@gjsify/adwaita-fonts": "^0.52.0",
50
- "@gjsify/adwaita-icons": "^0.52.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.52.0",
55
- "@gjsify/cli": "^0.52.0",
56
- "@gjsify/unit": "^0.52.0",
57
- "@gjsify/vite-plugin-blueprint": "^0.52.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
  },
@@ -118,8 +118,21 @@
118
118
  --spacing-l: 18px;
119
119
  --spacing-xl: 24px;
120
120
 
121
- // Typography — GNOME default: Adwaita Sans 11
122
- --font-family: 'Adwaita Sans', 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
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
- /** Set the accent custom properties for `name`. */
62
- export function applyAdwaitaAccent(name: AdwAccentColorName, options: ApplyAccentOptions = {}): void {
63
- const target = options.target ?? document.documentElement;
64
- const dark = options.dark ?? isAdwaitaDark(target);
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
- /** Drop the properties, so the stylesheet's own values apply again. */
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
+ };