@gjsify/adwaita-web 0.51.1 → 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.
Files changed (113) hide show
  1. package/README.md +34 -0
  2. package/dist/adwaita-web.css +259 -31
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/accent.d.ts +12 -2
  5. package/lib/types/adw-appearance.spec.d.ts +2 -0
  6. package/lib/types/appearance.d.ts +41 -0
  7. package/lib/types/blueprint-layout.spec.d.ts +1 -0
  8. package/lib/types/blueprint-markup.spec.d.ts +4 -0
  9. package/lib/types/blueprint-tree.spec.d.ts +1 -0
  10. package/lib/types/elements/adw-bottom-sheet.d.ts +31 -13
  11. package/lib/types/elements/adw-carousel.d.ts +5 -0
  12. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  13. package/lib/types/elements/adw-inline-view-switcher.d.ts +1 -0
  14. package/lib/types/elements/adw-navigation-view.d.ts +1 -0
  15. package/lib/types/elements/adw-sidebar.d.ts +10 -0
  16. package/lib/types/elements/adw-tab-view.d.ts +1 -0
  17. package/lib/types/elements/adw-toggle-group.d.ts +11 -0
  18. package/lib/types/elements/gtk-action-bar.d.ts +19 -0
  19. package/lib/types/elements/gtk-box.d.ts +17 -0
  20. package/lib/types/elements/gtk-button.d.ts +2 -0
  21. package/lib/types/elements/gtk-label.d.ts +122 -0
  22. package/lib/types/font-stack.spec.d.ts +2 -0
  23. package/lib/types/gio-menu.spec.d.ts +1 -0
  24. package/lib/types/gtk-box.spec.d.ts +1 -0
  25. package/lib/types/gtk-label.spec.d.ts +1 -0
  26. package/lib/types/gtk-value-doors.spec.d.ts +1 -0
  27. package/lib/types/index.d.ts +6 -1
  28. package/lib/types/namespace/gio.d.ts +1 -0
  29. package/lib/types/namespace/gtk.d.ts +5 -0
  30. package/lib/types/shared-tree-builder.d.ts +64 -0
  31. package/lib/types/source-view/asm6502.d.ts +1 -20
  32. package/lib/types/source-view/index.d.ts +3 -2
  33. package/lib/types/source-view/theme.d.ts +1 -1
  34. package/lib/types/string-list-slot.d.ts +4 -0
  35. package/lib/types/styles.generated.d.ts +1 -1
  36. package/lib/types/tags.spec.d.ts +1 -0
  37. package/lib/types/value-lists.spec.d.ts +1 -0
  38. package/package.json +8 -6
  39. package/scss/_action_bar.scss +51 -0
  40. package/scss/_banner.scss +8 -1
  41. package/scss/_bottom_sheet.scss +57 -0
  42. package/scss/_box.scss +23 -0
  43. package/scss/_button.scss +29 -7
  44. package/scss/_button_content.scss +6 -1
  45. package/scss/_button_row.scss +5 -2
  46. package/scss/_carousel.scss +6 -0
  47. package/scss/_labels.scss +126 -12
  48. package/scss/_navigation_split_view.scss +18 -0
  49. package/scss/_overlay_split_view.scss +5 -0
  50. package/scss/_reset.scss +29 -4
  51. package/scss/_split_button.scss +5 -2
  52. package/scss/_status_page.scss +31 -17
  53. package/scss/_theme.scss +11 -3
  54. package/scss/_toolbar_view.scss +10 -0
  55. package/scss/_variables.scss +23 -3
  56. package/scss/_widget.scss +55 -0
  57. package/scss/_window_title.scss +9 -0
  58. package/scss/adwaita-skin.scss +8 -2
  59. package/src/accent.ts +28 -5
  60. package/src/adw-appearance.spec.ts +178 -0
  61. package/src/adw-banner.spec.ts +16 -0
  62. package/src/adw-button-row.spec.ts +8 -0
  63. package/src/adw-header-bar.spec.ts +17 -0
  64. package/src/appearance.ts +207 -0
  65. package/src/blueprint-layout.spec.ts +223 -0
  66. package/src/blueprint-markup.spec.ts +155 -0
  67. package/src/blueprint-tree.spec.ts +174 -0
  68. package/src/bottom-sheet.spec.ts +166 -1
  69. package/src/chrome.spec.ts +5 -2
  70. package/src/elements/adw-bottom-sheet.ts +166 -59
  71. package/src/elements/adw-carousel.ts +10 -5
  72. package/src/elements/adw-clamp.ts +12 -0
  73. package/src/elements/adw-combo-row.ts +13 -4
  74. package/src/elements/adw-header-bar.ts +46 -3
  75. package/src/elements/adw-inline-view-switcher.ts +11 -0
  76. package/src/elements/adw-navigation-view.ts +10 -0
  77. package/src/elements/adw-sidebar.ts +22 -0
  78. package/src/elements/adw-spin-row.ts +44 -5
  79. package/src/elements/adw-status-page.ts +12 -6
  80. package/src/elements/adw-tab-view.ts +20 -3
  81. package/src/elements/adw-toggle-group.ts +45 -4
  82. package/src/elements/adw-toolbar-view.ts +9 -0
  83. package/src/elements/gtk-action-bar.ts +90 -0
  84. package/src/elements/gtk-box.ts +87 -0
  85. package/src/elements/gtk-button.ts +60 -13
  86. package/src/elements/gtk-drop-down.ts +10 -1
  87. package/src/elements/gtk-label.ts +415 -0
  88. package/src/elements/swipe-drag.ts +1 -1
  89. package/src/font-stack.spec.ts +110 -0
  90. package/src/gio-menu.spec.ts +125 -0
  91. package/src/globals.d.ts +7 -0
  92. package/src/gtk-box.spec.ts +168 -0
  93. package/src/gtk-button.spec.ts +123 -1
  94. package/src/gtk-label.spec.ts +595 -0
  95. package/src/gtk-value-doors.spec.ts +104 -0
  96. package/src/index.ts +32 -1
  97. package/src/keyboard-operable.spec.ts +5 -4
  98. package/src/namespace/gio.ts +19 -0
  99. package/src/namespace/gtk.ts +14 -0
  100. package/src/shared-tree-builder.ts +268 -0
  101. package/src/shared-trees.spec.ts +296 -32
  102. package/src/source-view/adw-source-view.ts +3 -3
  103. package/src/source-view/asm6502.ts +11 -191
  104. package/src/source-view/index.ts +17 -11
  105. package/src/source-view/theme.ts +23 -17
  106. package/src/split-button.spec.ts +7 -0
  107. package/src/string-list-slot.ts +21 -0
  108. package/src/styles.generated.ts +1 -1
  109. package/src/tags.spec.ts +51 -0
  110. package/src/test.browser.mts +25 -0
  111. package/src/value-lists.spec.ts +93 -0
  112. package/lib/types/source-view/hex.d.ts +0 -21
  113. package/src/source-view/hex.ts +0 -37
@@ -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
+ }
@@ -0,0 +1,223 @@
1
+ // A REAL `.blp` OF GtkWidget LAYOUT PROPERTIES, MOUNTED AND MEASURED.
2
+ //
3
+ // `bottom-sheet-layout.blp` (`@gjsify/adwaita-core/src/conformance/blueprints/`) is the shape
4
+ // the gallery's `Adw.BottomSheet` block writes: a button placed with `halign`/`valign`, a box
5
+ // inset by four `margin-*` lines, a title at the start edge, and one expanding child. The
6
+ // NativeScript port builds the same file (`adwaita-nativescript`'s `blueprint-trees.spec.ts`).
7
+ //
8
+ // Before `_widget.scss` and the builder's margin table, this renderer wrote every one of these
9
+ // as an attribute nothing read — no refusal, since the web builder refuses nothing it writes as
10
+ // an attribute, and no effect: the centred button stretched across the sheet's content. So the
11
+ // assertions here are about GEOMETRY, read off the laid-out page, never about the attribute
12
+ // being present.
13
+
14
+ import { describe, expect, it } from '@gjsify/unit';
15
+
16
+ import { mountSharedTree } from './shared-tree-builder.js';
17
+
18
+ import sheetTree from '../../adwaita-core/src/conformance/blueprints/bottom-sheet-layout.blp?shared-tree';
19
+ import carouselTree from '../../adwaita-core/src/conformance/blueprints/carousel-indicators.blp?shared-tree';
20
+
21
+ import { GALLERY_BLUEPRINTS } from './blueprint-markup.spec.js';
22
+
23
+ /** Mount the fixture at a size a layout can be measured in; the caller unmounts. */
24
+ function mountSheet() {
25
+ const mounted = mountSharedTree(sheetTree);
26
+ mounted.root.style.width = '400px';
27
+ mounted.root.style.height = '340px';
28
+ const byId = (id: string): HTMLElement => {
29
+ const el = mounted.root.querySelector<HTMLElement>(`#${id}`);
30
+ if (el === null) throw new Error(`the mounted tree has no element with the id '${id}'`);
31
+ return el;
32
+ };
33
+ return { ...mounted, byId };
34
+ }
35
+
36
+ export const AdwBlueprintLayoutTest = async () => {
37
+ await describe('adwaita-web: bottom-sheet-layout.blp, laid out', async () => {
38
+ await it('halign/valign center place the button in the middle of the content at its own size', async () => {
39
+ const { root, unmount, byId } = mountSheet();
40
+ try {
41
+ const content = root.querySelector('.adw-bottom-sheet-content') as HTMLElement;
42
+ const area = content.getBoundingClientRect();
43
+ const button = byId('toggle').getBoundingClientRect();
44
+
45
+ expect(button.width < area.width).toBe(true);
46
+ expect(button.height < area.height).toBe(true);
47
+ expect(Math.abs(button.left - area.left - (area.right - button.right)) <= 1).toBe(true);
48
+ expect(Math.abs(button.top - area.top - (area.bottom - button.bottom)) <= 1).toBe(true);
49
+ } finally {
50
+ unmount();
51
+ }
52
+ });
53
+
54
+ await it('the four margins inset the box, start and end on the logical edges', async () => {
55
+ const { unmount, byId } = mountSheet();
56
+ try {
57
+ const style = getComputedStyle(byId('box'));
58
+
59
+ expect(style.marginLeft).toBe('18px');
60
+ expect(style.marginRight).toBe('12px');
61
+ expect(style.marginTop).toBe('18px');
62
+ expect(style.marginBottom).toBe('24px');
63
+ } finally {
64
+ unmount();
65
+ }
66
+ });
67
+
68
+ await it('halign start keeps the title at the start edge of the box at its own width', async () => {
69
+ const { unmount, byId } = mountSheet();
70
+ try {
71
+ const box = byId('box').getBoundingClientRect();
72
+ const title = byId('title').getBoundingClientRect();
73
+
74
+ expect(Math.abs(title.left - box.left) <= 1).toBe(true);
75
+ expect(title.width < box.width).toBe(true);
76
+ } finally {
77
+ unmount();
78
+ }
79
+ });
80
+
81
+ await it('expand grows along the box axis only', async () => {
82
+ const { unmount, byId } = mountSheet();
83
+ try {
84
+ // The box is vertical: `vexpand` is its own axis and takes the spare height,
85
+ // `hexpand` is the cross axis, which `align-items: stretch` already fills.
86
+ expect(getComputedStyle(byId('body')).flexGrow).toBe('1');
87
+ expect(getComputedStyle(byId('caption')).flexGrow).toBe('0');
88
+ } finally {
89
+ unmount();
90
+ }
91
+ });
92
+
93
+ await it('the open modal sheet shows its dimming, and the non-modal one would not', async () => {
94
+ const { root, unmount } = mountSheet();
95
+ try {
96
+ const sheet = root as HTMLElement & { modal: boolean };
97
+ const dimming = root.querySelector('.adw-bottom-sheet-dimming') as HTMLElement;
98
+
99
+ expect(dimming.classList.contains('visible')).toBe(true);
100
+ sheet.modal = false;
101
+ expect(dimming.classList.contains('visible')).toBe(false);
102
+ } finally {
103
+ unmount();
104
+ }
105
+ });
106
+ });
107
+
108
+ // `carousel: carousel` projects as the attribute `carousel="carousel"`: GTK's property
109
+ // name, which the indicators now read beside the `for` they had first.
110
+ await describe('adwaita-web: carousel-indicators.blp, mounted', async () => {
111
+ await it('both indicators bind to the carousel by id and mark its first page', async () => {
112
+ const { root, unmount } = mountSharedTree(carouselTree);
113
+ try {
114
+ const dots = [...root.querySelectorAll('#dots .adw-carousel-dot')];
115
+ const lines = [...root.querySelectorAll('#lines .adw-carousel-line')];
116
+
117
+ expect(dots.length).toBe(3);
118
+ expect(lines.length).toBe(3);
119
+ expect(dots.map((dot) => dot.classList.contains('active'))).toStrictEqual([true, false, false]);
120
+ expect(lines[0]?.classList.contains('active')).toBe(true);
121
+ } finally {
122
+ unmount();
123
+ }
124
+ });
125
+ });
126
+
127
+ // The gallery's third batch of `.blp` files, mounted at the size of the gallery's stage
128
+ // and held to a GTK render of the same file (GJS, `Gtk.Builder`, an `Adw.ApplicationWindow`
129
+ // of that size, libadwaita 1.9). The GTK numbers are written beside each assertion; the
130
+ // tolerance is the 1-2px a header bar and text metrics differ by.
131
+ await describe('adwaita-web: the gallery Blueprints, laid out beside GTK', async () => {
132
+ /** The tree mounted in a grid of `width` x `height`, as the gallery stage holds it. */
133
+ const mountAt = (file: string, width: number, height: number) => {
134
+ const tree = GALLERY_BLUEPRINTS[file];
135
+ if (tree === undefined) throw new Error(`no gallery Blueprint ${file}`);
136
+ const mounted = mountSharedTree(tree);
137
+ const host = mounted.root.parentElement as HTMLElement;
138
+ host.style.cssText = `display: grid; width: ${width}px; height: ${height}px;`;
139
+ const box = (el: Element) => {
140
+ const r = el.getBoundingClientRect();
141
+ const h = host.getBoundingClientRect();
142
+ return { x: r.left - h.left, y: r.top - h.top, width: r.width, height: r.height };
143
+ };
144
+ const byId = (id: string): HTMLElement => {
145
+ const el = host.querySelector<HTMLElement>(`#${id}`);
146
+ if (el === null) throw new Error(`${file} mounted no element with the id '${id}'`);
147
+ return el;
148
+ };
149
+ return { ...mounted, host, box, byId };
150
+ };
151
+ const near = (actual: number, gtk: number, tolerance = 2) => Math.abs(actual - gtk) <= tolerance;
152
+
153
+ await it('a split view page fills its pane, and its header bar shows the page title', async () => {
154
+ const { unmount, box, byId, host } = mountAt('adwaita/navigation-split-view.blp', 652, 340);
155
+ try {
156
+ // GTK: sidebar 0,46 180x294 — the list fills the pane under the header bar.
157
+ expect(near(box(byId('sidebar')).height, 294)).toBe(true);
158
+ const titles = [...host.querySelectorAll('adw-header-bar')].map(
159
+ (bar) => bar.querySelector('adw-window-title')?.getAttribute('title') ?? null,
160
+ );
161
+ expect(titles).toStrictEqual(['Mailboxes', 'All Mail']);
162
+ } finally {
163
+ unmount();
164
+ }
165
+ });
166
+
167
+ await it('an overlay split view sidebar fills its pane', async () => {
168
+ const { unmount, box, byId } = mountAt('adwaita/overlay-split-view.blp', 652, 340);
169
+ try {
170
+ // GTK: sidebar 0,46 180x294.
171
+ expect(near(box(byId('sidebar')).height, 294)).toBe(true);
172
+ } finally {
173
+ unmount();
174
+ }
175
+ });
176
+
177
+ await it('a centred toolbar-view content widget keeps its own height', async () => {
178
+ const { unmount, box, byId } = mountAt('adwaita/navigation-view.blp', 652, 340);
179
+ try {
180
+ // GTK: open_button 245,171 161x44.
181
+ const button = box(byId('open_button'));
182
+ expect(near(button.height, 44)).toBe(true);
183
+ expect(near(button.y, 171)).toBe(true);
184
+ } finally {
185
+ unmount();
186
+ }
187
+ });
188
+
189
+ await it("a status page has upstream's metrics: a 128px icon, the child where GTK puts it", async () => {
190
+ const { unmount, box, byId, host } = mountAt('adwaita/status-page.blp', 652, 400);
191
+ try {
192
+ const icon = host.querySelector('.adw-status-page-icon') as HTMLElement;
193
+ expect(box(icon).width).toBe(128);
194
+ expect(box(icon).height).toBe(128);
195
+ // GTK: new_button 239,311 174x44.
196
+ expect(near(box(byId('new_button')).y, 311, 3)).toBe(true);
197
+ } finally {
198
+ unmount();
199
+ }
200
+ });
201
+
202
+ await it('a carousel does not widen the box it fills to the sum of its pages', async () => {
203
+ const { unmount, box, root } = mountAt('adwaita/carousel.blp', 652, 260);
204
+ try {
205
+ // GTK: the box is 652 wide, the window's width.
206
+ expect(box(root).width).toBe(652);
207
+ } finally {
208
+ unmount();
209
+ }
210
+ });
211
+
212
+ await it('tooltip-text becomes the icon button tooltip and accessible name', async () => {
213
+ const { unmount, byId } = mountAt('adwaita/toolbar-view.blp', 652, 300);
214
+ try {
215
+ const inner = byId('add_button').querySelector('button') as HTMLButtonElement;
216
+ expect(inner.title).toBe('Add');
217
+ expect(inner.getAttribute('aria-label')).toBe('Add');
218
+ } finally {
219
+ unmount();
220
+ }
221
+ });
222
+ });
223
+ };
@@ -0,0 +1,155 @@
1
+ // The custom-element markup of the `index.html` file the gallery's Web Components tab shows for
2
+ // a one-Blueprint block, PARSED back by the browser and held to the tree `buildSharedTree`
3
+ // makes from the same `.blp`.
4
+ //
5
+ // The website renders that markup with `@gjsify/adwaita-core/markup` from the block's
6
+ // `?shared-tree` projection, on every build. Markup a reader copies has to build the same
7
+ // widget: an attribute spelled against `attributeOf`, a boolean written as `"true"` where the
8
+ // element reads presence, or a slot dropped would each parse fine and mount something else. So every `.blp` under
9
+ // `website/src/blueprints/` is imported below — `scripts/check-website-blueprint-markup.mjs`
10
+ // fails on one that is not.
11
+
12
+ import { describe, expect, it } from '@gjsify/unit';
13
+
14
+ import type { SharedTreeNode } from '@gjsify/adwaita-core/conformance';
15
+ import { sharedTreeHtml } from '@gjsify/adwaita-core/markup';
16
+ import { hostTagOf } from '@gjsify/adwaita-core/tags';
17
+
18
+ import adwaitaActionRowTree from '../../../../website/src/blueprints/adwaita/action-row.blp?shared-tree';
19
+ import adwaitaAvatarTree from '../../../../website/src/blueprints/adwaita/avatar.blp?shared-tree';
20
+ import adwaitaBannerTree from '../../../../website/src/blueprints/adwaita/banner.blp?shared-tree';
21
+ import adwaitaBottomSheetTree from '../../../../website/src/blueprints/adwaita/bottom-sheet.blp?shared-tree';
22
+ import adwaitaButtonContentTree from '../../../../website/src/blueprints/adwaita/button-content.blp?shared-tree';
23
+ import adwaitaButtonRowTree from '../../../../website/src/blueprints/adwaita/button-row.blp?shared-tree';
24
+ import adwaitaCarouselTree from '../../../../website/src/blueprints/adwaita/carousel.blp?shared-tree';
25
+ import clampTree from '../../../../website/src/blueprints/adwaita/clamp.blp?shared-tree';
26
+ import adwaitaComboRowTree from '../../../../website/src/blueprints/adwaita/combo-row.blp?shared-tree';
27
+ import adwaitaEntryRowTree from '../../../../website/src/blueprints/adwaita/entry-row.blp?shared-tree';
28
+ import adwaitaExpanderRowTree from '../../../../website/src/blueprints/adwaita/expander-row.blp?shared-tree';
29
+ import adwaitaHeaderBarTree from '../../../../website/src/blueprints/adwaita/header-bar.blp?shared-tree';
30
+ import adwaitaNavigationSplitViewTree from '../../../../website/src/blueprints/adwaita/navigation-split-view.blp?shared-tree';
31
+ import adwaitaNavigationViewTree from '../../../../website/src/blueprints/adwaita/navigation-view.blp?shared-tree';
32
+ import adwaitaOverlaySplitViewTree from '../../../../website/src/blueprints/adwaita/overlay-split-view.blp?shared-tree';
33
+ import adwaitaPasswordEntryRowTree from '../../../../website/src/blueprints/adwaita/password-entry-row.blp?shared-tree';
34
+ import adwaitaPreferencesGroupTree from '../../../../website/src/blueprints/adwaita/preferences-group.blp?shared-tree';
35
+ import adwaitaShortcutLabelTree from '../../../../website/src/blueprints/adwaita/shortcut-label.blp?shared-tree';
36
+ import adwaitaSidebarTree from '../../../../website/src/blueprints/adwaita/sidebar.blp?shared-tree';
37
+ import adwaitaSpinRowTree from '../../../../website/src/blueprints/adwaita/spin-row.blp?shared-tree';
38
+ import adwaitaSplitButtonTree from '../../../../website/src/blueprints/adwaita/split-button.blp?shared-tree';
39
+ import adwaitaStatusPageTree from '../../../../website/src/blueprints/adwaita/status-page.blp?shared-tree';
40
+ import adwaitaSwitchRowTree from '../../../../website/src/blueprints/adwaita/switch-row.blp?shared-tree';
41
+ import adwaitaToggleGroupTree from '../../../../website/src/blueprints/adwaita/toggle-group.blp?shared-tree';
42
+ import adwaitaToolbarViewTree from '../../../../website/src/blueprints/adwaita/toolbar-view.blp?shared-tree';
43
+ import adwaitaViewSwitcherBarTree from '../../../../website/src/blueprints/adwaita/view-switcher-bar.blp?shared-tree';
44
+ import adwaitaWindowTitleTree from '../../../../website/src/blueprints/adwaita/window-title.blp?shared-tree';
45
+ import adwaitaWrapBoxTree from '../../../../website/src/blueprints/adwaita/wrap-box.blp?shared-tree';
46
+ import gtkButtonTree from '../../../../website/src/blueprints/gtk/button.blp?shared-tree';
47
+ import gtkMenuButtonTree from '../../../../website/src/blueprints/gtk/menu-button.blp?shared-tree';
48
+
49
+ import { buildSharedTree } from './shared-tree-builder.js';
50
+
51
+ /** Every one-Blueprint `.blp` of the gallery, by its path under `website/src/blueprints/`. */
52
+ export const GALLERY_BLUEPRINTS: Readonly<Record<string, SharedTreeNode>> = {
53
+ 'adwaita/action-row.blp': adwaitaActionRowTree,
54
+ 'adwaita/avatar.blp': adwaitaAvatarTree,
55
+ 'adwaita/banner.blp': adwaitaBannerTree,
56
+ 'adwaita/bottom-sheet.blp': adwaitaBottomSheetTree,
57
+ 'adwaita/button-content.blp': adwaitaButtonContentTree,
58
+ 'adwaita/button-row.blp': adwaitaButtonRowTree,
59
+ 'adwaita/carousel.blp': adwaitaCarouselTree,
60
+ 'adwaita/clamp.blp': clampTree,
61
+ 'adwaita/combo-row.blp': adwaitaComboRowTree,
62
+ 'adwaita/entry-row.blp': adwaitaEntryRowTree,
63
+ 'adwaita/expander-row.blp': adwaitaExpanderRowTree,
64
+ 'adwaita/header-bar.blp': adwaitaHeaderBarTree,
65
+ 'adwaita/navigation-split-view.blp': adwaitaNavigationSplitViewTree,
66
+ 'adwaita/navigation-view.blp': adwaitaNavigationViewTree,
67
+ 'adwaita/overlay-split-view.blp': adwaitaOverlaySplitViewTree,
68
+ 'adwaita/password-entry-row.blp': adwaitaPasswordEntryRowTree,
69
+ 'adwaita/preferences-group.blp': adwaitaPreferencesGroupTree,
70
+ 'adwaita/shortcut-label.blp': adwaitaShortcutLabelTree,
71
+ 'adwaita/sidebar.blp': adwaitaSidebarTree,
72
+ 'adwaita/spin-row.blp': adwaitaSpinRowTree,
73
+ 'adwaita/split-button.blp': adwaitaSplitButtonTree,
74
+ 'adwaita/status-page.blp': adwaitaStatusPageTree,
75
+ 'adwaita/switch-row.blp': adwaitaSwitchRowTree,
76
+ 'adwaita/toggle-group.blp': adwaitaToggleGroupTree,
77
+ 'adwaita/toolbar-view.blp': adwaitaToolbarViewTree,
78
+ 'adwaita/view-switcher-bar.blp': adwaitaViewSwitcherBarTree,
79
+ 'adwaita/window-title.blp': adwaitaWindowTitleTree,
80
+ 'adwaita/wrap-box.blp': adwaitaWrapBoxTree,
81
+ 'gtk/button.blp': gtkButtonTree,
82
+ 'gtk/menu-button.blp': gtkMenuButtonTree,
83
+ };
84
+
85
+ /**
86
+ * An attribute's value as the comparison reads it. `style` is read as the declarations it
87
+ * sets, one longhand each and sorted: which of the builder's margin and an element's own
88
+ * `flex-direction` was written first, and whether the serialiser folds two margins into a
89
+ * `margin-inline`, is text neither side chose and no style resolves differently for.
90
+ */
91
+ const attributeText = (el: Element, attribute: Attr): string => {
92
+ if (attribute.name !== 'style') return attribute.value;
93
+ const style = (el as HTMLElement).style;
94
+ return [...style]
95
+ .map((property) => `${property}: ${style.getPropertyValue(property)}`)
96
+ .sort()
97
+ .join('; ');
98
+ };
99
+
100
+ /**
101
+ * An element tree as text a comparison can hold: the tag, the attributes SORTED (their order is
102
+ * the one thing markup and a builder may legitimately disagree on, and no element reads it), and
103
+ * the children, with the indentation between elements dropped since the builder never makes any.
104
+ */
105
+ const canonical = (el: Element, depth = 0): string => {
106
+ const attributes = [...el.attributes].map((a) => `${a.name}=${JSON.stringify(attributeText(el, a))}`).sort();
107
+ const pad = ' '.repeat(depth);
108
+ const lines = [`${pad}<${el.localName}${attributes.map((a) => ` ${a}`).join('')}>`];
109
+ for (const node of el.childNodes) {
110
+ if (node.nodeType === Node.ELEMENT_NODE) lines.push(canonical(node as Element, depth + 1));
111
+ else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== '') {
112
+ lines.push(`${pad} ${JSON.stringify(node.textContent)}`);
113
+ }
114
+ }
115
+ return lines.join('\n');
116
+ };
117
+
118
+ const withIds = (node: SharedTreeNode, into: SharedTreeNode[] = []): SharedTreeNode[] => {
119
+ if (node.id !== undefined) into.push(node);
120
+ for (const child of node.children ?? []) withIds(child, into);
121
+ return into;
122
+ };
123
+
124
+ export const AdwBlueprintMarkupTest = async () => {
125
+ await describe('adwaita-web: the gallery’s markup, parsed', async () => {
126
+ for (const [file, tree] of Object.entries(GALLERY_BLUEPRINTS)) {
127
+ // Both sides DETACHED and upgraded: a detached build runs each element's constructor
128
+ // and attribute callbacks (a label renders its text on `label=`), and so does parsing
129
+ // in this document. Neither is connected, so what differs is only what was authored.
130
+ await it(`${file}: parses to the element tree buildSharedTree makes`, async () => {
131
+ const parsed = document.createRange().createContextualFragment(sharedTreeHtml(tree));
132
+ expect(canonical(parsed.firstElementChild!)).toBe(canonical(buildSharedTree(tree)));
133
+ });
134
+
135
+ await it(`${file}: mounted, every id reaches an upgraded element of its tag`, async () => {
136
+ const host = document.createElement('div');
137
+ host.innerHTML = sharedTreeHtml(tree);
138
+ document.body.append(host);
139
+ try {
140
+ const nodes = withIds(tree);
141
+ expect(nodes.length > 0).toBe(true);
142
+ for (const node of nodes) {
143
+ const found = host.querySelector(`#${node.id}`);
144
+ const tag = hostTagOf(node.tag);
145
+ expect(found?.localName).toBe(tag);
146
+ const defined = customElements.get(tag);
147
+ expect(defined !== undefined && found instanceof defined).toBe(true);
148
+ }
149
+ } finally {
150
+ host.remove();
151
+ }
152
+ });
153
+ }
154
+ });
155
+ };