@gjsify/adwaita-web 0.40.0 → 0.42.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 +98 -3
  2. package/dist/adwaita-web.css +142 -63
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-fonts.spec.d.ts +2 -0
  5. package/lib/types/breakpoints.d.ts +10 -1
  6. package/lib/types/connect-lifecycle.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-about-dialog.d.ts +1 -0
  8. package/lib/types/elements/adw-action-row.d.ts +2 -0
  9. package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
  10. package/lib/types/elements/adw-carousel.d.ts +48 -0
  11. package/lib/types/elements/adw-dialog.d.ts +2 -7
  12. package/lib/types/elements/adw-icon.d.ts +17 -1
  13. package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
  14. package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
  15. package/lib/types/elements/adw-popover.d.ts +1 -0
  16. package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
  17. package/lib/types/elements/adw-switch.d.ts +10 -0
  18. package/lib/types/elements/adw-tab-view.d.ts +23 -0
  19. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  20. package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
  21. package/lib/types/elements/modal-surface.d.ts +59 -0
  22. package/lib/types/elements/roving-focus.d.ts +26 -0
  23. package/lib/types/elements/row-activation.d.ts +26 -0
  24. package/lib/types/empty-sections.d.ts +15 -0
  25. package/lib/types/empty-sections.spec.d.ts +1 -0
  26. package/lib/types/fonts.d.ts +1 -0
  27. package/lib/types/icon-registry.d.ts +35 -0
  28. package/lib/types/icon-registry.spec.d.ts +18 -0
  29. package/lib/types/index.d.ts +3 -1
  30. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  31. package/lib/types/scroll-shading.d.ts +56 -0
  32. package/lib/types/styles.generated.d.ts +1 -1
  33. package/package.json +10 -6
  34. package/scss/_banner.scss +1 -1
  35. package/scss/_carousel.scss +15 -1
  36. package/scss/_checks.scss +8 -8
  37. package/scss/_combo_row.scss +1 -1
  38. package/scss/_drop_down.scss +1 -1
  39. package/scss/_expander_row.scss +1 -1
  40. package/scss/_headerbar.scss +35 -6
  41. package/scss/_icon.scss +47 -0
  42. package/scss/_icons.generated.scss +36 -6
  43. package/scss/_password_entry_row.scss +1 -1
  44. package/scss/_preferences_dialog.scss +3 -2
  45. package/scss/_progress_bar.scss +1 -1
  46. package/scss/_row.scss +1 -1
  47. package/scss/_scrolling.scss +107 -0
  48. package/scss/_status_page.scss +1 -1
  49. package/scss/_style_classes.scss +21 -8
  50. package/scss/_tab_view.scss +1 -1
  51. package/scss/_theme.scss +1 -0
  52. package/scss/_toast.scss +6 -24
  53. package/scss/_toolbar_view.scss +52 -22
  54. package/scss/_variables.scss +30 -3
  55. package/scss/_view_switcher.scss +80 -20
  56. package/scss/adwaita-skin.scss +1 -0
  57. package/src/adw-action-rows.spec.ts +21 -9
  58. package/src/adw-button-content.spec.ts +11 -6
  59. package/src/adw-carousel.spec.ts +141 -1
  60. package/src/adw-fonts.spec.ts +188 -0
  61. package/src/adw-header-bar.spec.ts +92 -0
  62. package/src/adw-icon.spec.ts +17 -8
  63. package/src/adw-row-state.spec.ts +11 -1
  64. package/src/adw-tab-view.spec.ts +151 -0
  65. package/src/bottom-sheet.spec.ts +87 -0
  66. package/src/breakpoints.ts +18 -4
  67. package/src/chrome.spec.ts +233 -0
  68. package/src/connect-lifecycle.spec.ts +351 -0
  69. package/src/elements/adw-about-dialog.ts +24 -18
  70. package/src/elements/adw-action-row.ts +14 -2
  71. package/src/elements/adw-alert-dialog.ts +39 -20
  72. package/src/elements/adw-button-row.ts +3 -0
  73. package/src/elements/adw-carousel.ts +121 -4
  74. package/src/elements/adw-clamp.ts +3 -2
  75. package/src/elements/adw-data-grid.ts +5 -2
  76. package/src/elements/adw-dialog.ts +16 -63
  77. package/src/elements/adw-expander-row.ts +18 -4
  78. package/src/elements/adw-header-bar.ts +2 -0
  79. package/src/elements/adw-icon.ts +32 -6
  80. package/src/elements/adw-inline-view-switcher.ts +12 -0
  81. package/src/elements/adw-menu-button.ts +17 -10
  82. package/src/elements/adw-navigation-split-view.ts +30 -7
  83. package/src/elements/adw-overlay-split-view.ts +15 -3
  84. package/src/elements/adw-popover.ts +11 -0
  85. package/src/elements/adw-preferences-dialog.ts +18 -16
  86. package/src/elements/adw-preferences-group.ts +7 -0
  87. package/src/elements/adw-sidebar.ts +15 -0
  88. package/src/elements/adw-status-page.ts +12 -4
  89. package/src/elements/adw-switch-row.ts +7 -0
  90. package/src/elements/adw-switch.ts +19 -1
  91. package/src/elements/adw-tab-view.ts +70 -7
  92. package/src/elements/adw-toast-overlay.ts +8 -1
  93. package/src/elements/adw-toggle-group.ts +94 -2
  94. package/src/elements/adw-toolbar-view.ts +28 -2
  95. package/src/elements/adw-view-switcher-bar.ts +18 -0
  96. package/src/elements/adw-view-switcher.ts +14 -0
  97. package/src/elements/modal-surface.ts +180 -0
  98. package/src/elements/roving-focus.ts +154 -0
  99. package/src/elements/row-activation.ts +92 -0
  100. package/src/empty-sections.spec.ts +81 -0
  101. package/src/empty-sections.ts +54 -0
  102. package/src/fonts.ts +25 -0
  103. package/src/icon-registry.spec.ts +240 -0
  104. package/src/icon-registry.ts +157 -0
  105. package/src/index.ts +30 -1
  106. package/src/keyboard-operable.spec.ts +755 -0
  107. package/src/scroll-shading.ts +255 -0
  108. package/src/source-view/theme.ts +15 -3
  109. package/src/split-button.spec.ts +6 -2
  110. package/src/style-classes.spec.ts +6 -0
  111. package/src/styles.generated.ts +1 -1
  112. package/src/test.browser.mts +14 -0
  113. package/src/view-switcher.spec.ts +149 -0
@@ -183,7 +183,7 @@ adw-tab-view {
183
183
  background-color: var(--accent-bg-color);
184
184
  }
185
185
 
186
- // --- Close affordance (CSS-drawn "×", no symbolic icon ships) ---
186
+ // --- Close affordance (CSS-drawn "×"; `window-close` ships, see adw-tab-view.ts) ---
187
187
  // Presence is decided in JS by `tabCloseVisible` — `(hovering && fullyVisible)
188
188
  // || selected || dragging`, and never on a pinned tab (adw-tab.c:124, :645-650)
189
189
  // — which is why the `[hidden]` rule below is load-bearing rather than cosmetic.
package/scss/_theme.scss CHANGED
@@ -17,6 +17,7 @@
17
17
  --headerbar-fg-color: #ffffff;
18
18
  --headerbar-shade-color: rgba(0, 0, 6, 0.36);
19
19
  --separator-color: rgba(255, 255, 255, 0.08);
20
+ --shade-color: rgba(0, 0, 6, 0.25);
20
21
  --sidebar-bg-color: #2e2e32;
21
22
  --sidebar-fg-color: #ffffff;
22
23
  --sidebar-backdrop-color: #28282c;
package/scss/_toast.scss CHANGED
@@ -5,7 +5,7 @@
5
5
  // up on show / down on dismiss (mirroring Adw.Toast).
6
6
  // Reference: refs/adwaita-web/adwaita-web/scss/_toast.scss
7
7
  // Reference: refs/adwaita-web/adwaita-web/scss/_toast_overlay.scss
8
- // Reference: refs/libadwaita/src/stylesheet/widgets/_toast.scss
8
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_misc.scss (Toasts)
9
9
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
10
10
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
11
11
  // Modifications: Adapted for @gjsify/adwaita-web Web Components.
@@ -120,12 +120,14 @@ adw-toast-overlay {
120
120
  }
121
121
  }
122
122
 
123
- // Close affordance — a CSS-drawn "×" (mirrors adw-tab-view's .adw-tab-close;
124
- // no symbolic close icon ships in the curated adwaita-web icon set).
123
+ // Close affordance — `window-close-symbolic` in a 24px circle, as upstream builds it
124
+ // (adw-toast-widget.ui:64). The element supplies the <adw-icon>; this only places it.
125
125
  .adw-toast-close-button {
126
126
  box-sizing: border-box;
127
127
  flex-shrink: 0;
128
- position: relative;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
129
131
  width: 24px;
130
132
  height: 24px;
131
133
  margin: 0;
@@ -149,25 +151,5 @@ adw-toast-overlay {
149
151
  outline: 2px solid var(--accent-color);
150
152
  outline-offset: 1px;
151
153
  }
152
-
153
- &::before,
154
- &::after {
155
- content: '';
156
- position: absolute;
157
- top: 50%;
158
- left: 50%;
159
- width: 11px;
160
- height: 1.5px;
161
- background-color: currentColor;
162
- border-radius: 1px;
163
- }
164
-
165
- &::before {
166
- transform: translate(-50%, -50%) rotate(45deg);
167
- }
168
-
169
- &::after {
170
- transform: translate(-50%, -50%) rotate(-45deg);
171
- }
172
154
  }
173
155
  }
@@ -31,6 +31,35 @@ adw-toolbar-view {
31
31
  background-color: var(--window-bg-color);
32
32
  }
33
33
 
34
+ // THE CONTENT WIDGET FILLS THE CONTENT RECT. `adw_toolbar_view_size_allocate`
35
+ // (adw-toolbar-view.c:390-397) hands `self->content` the whole rect between the
36
+ // bars, and a GTK widget that wants less of it says so through its own
37
+ // alignment. This port collects the content into a flex COLUMN, where an item
38
+ // declaring no `flex-grow` is content-sized instead, so the child stopped
39
+ // wherever its content did and the pane below it stayed empty. Measured on the
40
+ // docs Navigation Split View preview: a 293px sidebar pane holding a 176px
41
+ // `adw-sidebar`, i.e. 117px of window-coloured dead strip under the last row,
42
+ // and beside it a status page top-aligned in a pane it was supposed to centre in.
43
+ // The web storybook had been papering over it in its own sheet
44
+ // (`.sb-sidebar-scroll { flex: 1 1 auto }`), deleted now that the widget carries
45
+ // it: a per-consumer copy is what a missing widget rule looks like from the
46
+ // outside, and it keeps the widget green while the next consumer hits the same
47
+ // wall.
48
+ //
49
+ // `:only-child` because Adw.ToolbarView HAS one content widget. Several stacked
50
+ // children are this port's own extension, and GTK defines no distribution for
51
+ // them, so inventing one here would be a claim rather than a copy.
52
+ //
53
+ // NO `min-height: 0` alongside it, deliberately: the automatic minimum size is
54
+ // already the right answer per child. A scroller (`adw-sidebar`, any
55
+ // `overflow: auto` box) gets an automatic minimum of 0 and shrinks to scroll
56
+ // itself, as its GtkScrolledWindow would; a status page keeps its content-based
57
+ // minimum, so a pane too short to hold it scrolls THIS box instead of crushing a
58
+ // centred column into overflow its reader cannot scroll back up to.
59
+ .adw-toolbar-view-content > :only-child {
60
+ flex: 1 1 auto;
61
+ }
62
+
34
63
  // top-bar-style / bottom-bar-style: an opaque background plus a persistent
35
64
  // shadow, which `.border` swaps for a hairline (adw-toolbar-view.c:1011-1024,
36
65
  // _toolbars.scss `> .top-bar.raised`).
@@ -60,30 +89,31 @@ adw-toolbar-view {
60
89
  }
61
90
  }
62
91
 
63
- // The scroll fade under a FLAT bar. libadwaita paints it on the scrolled
64
- // window; the browser equivalent is a non-interactive gradient stuck to the
65
- // scrollport edge, taking no space of its own — a NEGATIVE margin equal to its
66
- // height (_toolbars.scss :342-343, _scrolling.scss :150-157).
67
- &.undershoot-top .adw-toolbar-view-content::before,
68
- &.undershoot-bottom .adw-toolbar-view-content::after {
69
- content: '';
70
- position: sticky;
71
- height: 6px;
72
- flex-shrink: 0;
73
- pointer-events: none;
74
- }
75
-
76
- &.undershoot-top .adw-toolbar-view-content::before {
77
- top: 0;
78
- margin-bottom: -6px;
79
- background: linear-gradient(to bottom, var(--headerbar-shade-color), transparent);
92
+ // A bar in a toolbar view is INLINE — `%headerbar-inline` → `%headerbar-flat`:
93
+ // no background of its own, no separator, the content's foreground
94
+ // (_toolbars.scss :313-317, _header-bar.scss :110-147). That is what makes a
95
+ // flat-style view read as ONE surface with the bar sitting in it rather than as
96
+ // a bar bolted on top of a pane, and it is why the shading below is the ONLY
97
+ // thing that ever draws a line between them.
98
+ //
99
+ // Not folded into `%headerbar` itself: a header bar used as a WINDOW titlebar
100
+ // keeps its own background and shade, which is what `adw-header-bar` outside a
101
+ // toolbar view still renders.
102
+ .adw-toolbar-view-top,
103
+ .adw-toolbar-view-bottom {
104
+ &:not(.raised) adw-header-bar {
105
+ background: none;
106
+ color: inherit;
107
+ box-shadow: none;
108
+ }
80
109
  }
81
110
 
82
- &.undershoot-bottom .adw-toolbar-view-content::after {
83
- bottom: 0;
84
- margin-top: -6px;
85
- background: linear-gradient(to top, var(--headerbar-shade-color), transparent);
86
- }
111
+ // The scroll shading under a FLAT bar belongs to the SCROLLER, not to the view:
112
+ // upstream's selector is `toolbarview.undershoot-top scrolledwindow`, and GTK
113
+ // draws that node only while the scroller is off the edge. Drawn from the view
114
+ // instead — which is what this did — the fade is unconditional, so every flat
115
+ // bar sat behind a permanent hairline that native shows only after scrolling.
116
+ // `_scrolling.scss` + `src/scroll-shading.ts` now own it, per scroller.
87
117
  }
88
118
 
89
119
  // extend-content-to-*-edge: the content spans the full height and the bar is
@@ -24,6 +24,13 @@
24
24
  // draws separators, instead of a harsh near-black line on a dark surface.
25
25
  --separator-color: rgba(0, 0, 6, 0.1);
26
26
 
27
+ // The GENERIC shade the whole `*-shade-color` family below specialises
28
+ // (`_colors.scss:79,141`). Scroll undershoots are drawn from it, and it belongs
29
+ // to the palette rather than to any one widget: a scroller can sit on a window,
30
+ // a view, a sidebar or a card, and reading its neighbour's shade would make the
31
+ // same indicator four different colours.
32
+ --shade-color: rgba(0, 0, 6, 0.07);
33
+
27
34
  // Sidebar / split panes. libadwaita defines the whole family, not just the
28
35
  // background (_colors.scss:47-58), and only the background was here — so
29
36
  // `.sidebar-pane` had no foreground to name, and an app overriding
@@ -137,9 +144,29 @@
137
144
  // by AdwStyleManager from the desktop's font settings
138
145
  // (adw-style-manager.c:169-218), defaulting to `Sans 10` / `Monospace 10`
139
146
  // (:50-56). A browser has no style manager, so they are ordinary tokens here
140
- // — same names, so re-theming goes through the documented contract. The
141
- // monospace stack is the one `_data_grid.scss` used to inline; it has ONE home
142
- // now, and Adwaita Mono heads it because `@gjsify/adwaita-fonts` ships it.
147
+ // — same names, so re-theming goes through the documented contract.
148
+ //
149
+ // The monospace stack has ONE home, and did not until recently: `_data_grid.scss`
150
+ // used to inline it, and `source-view/theme.ts` carried a SECOND, longer copy in
151
+ // a TS string literal (measured on one page, `.cm-scroller` resolved to
152
+ // 'Adwaita Mono', 'Cascadia Code', 'JetBrains Mono', … while a `.monospace`
153
+ // label beside it resolved to this token — two typefaces, same document).
154
+ // A literal in TS is also outside the `stylesheet-font-families` rule, which
155
+ // reads `.css`/`.scss`. Both now read this token; its consumers are
156
+ // `.monospace` (_labels.scss:128), `_action_row.scss:94`, the data-grid mono
157
+ // cell (via `@extend .monospace`) and `<adw-source-view>`.
158
+ //
159
+ // Adwaita Mono heads the stack as a PROGRESSIVE ENHANCEMENT, not because
160
+ // anything here ships it: `@gjsify/adwaita-fonts` vendors two Sans TTFs and no
161
+ // mono face at all (the upstream mono faces are 1.4-1.5 MB each). This comment
162
+ // used to claim the package ships it — a claim the NativeScript theme, which
163
+ // carries the same stack, already contradicted in prose
164
+ // (`nativescript-bridge/adwaita/src/theme/adwaita.css:769`). So on a GNOME
165
+ // desktop the system `adwaita-mono-fonts` resolves it; anywhere else the
166
+ // generic tail is what renders, which is the correct outcome and the reason the
167
+ // tail exists. Held honest by the `stylesheet-font-families` conformance rule,
168
+ // whose ledger carries this reason — never delete one without the other.
169
+ //
143
170
  // `'Cantarell'` is dropped from that stack on the way here: Cantarell is
144
171
  // GNOME's PROPORTIONAL UI font, so a monospace fallback chain that reaches it
145
172
  // second lands on a font whose figures do not line up — the exact property
@@ -22,12 +22,19 @@ adw-view-switcher {
22
22
  min-height: 34px;
23
23
  }
24
24
 
25
- // Each tab is a flat, transparent toggle that lights up with a subtle fill
26
- // when hovered and shows the active page with an accent-tinted background.
25
+ // Each tab is a flat, transparent toggle that lights up with a subtle
26
+ // neutral fill when hovered and a slightly stronger one while selected.
27
27
  .adw-view-switcher-button {
28
28
  box-sizing: border-box;
29
29
  display: inline-flex;
30
30
  flex: 1 1 0; // homogeneous — every tab gets equal width
31
+ // `min-width: auto` is the flex-item default, and it floors every tab at
32
+ // its own min-content width, so a long title made ITS tab wider than the
33
+ // others and homogeneity silently stopped holding at exactly the widths
34
+ // where it matters. GTK's homogeneous box has no such floor
35
+ // (adw-view-switcher.c:475) and the labels ellipsize instead
36
+ // (adw-view-switcher-button.ui:42), which is what the 0 restores.
37
+ min-width: 0;
31
38
  align-items: center;
32
39
  justify-content: center;
33
40
  margin: 0;
@@ -63,6 +70,10 @@ adw-view-switcher {
63
70
  }
64
71
 
65
72
  .adw-view-switcher-label {
73
+ // Same min-content floor as the button, one level down: without it the
74
+ // `overflow: hidden` box never shrinks, so the ellipsis never appears
75
+ // and the label pushes its tab wide instead.
76
+ min-width: 0;
66
77
  white-space: nowrap;
67
78
  overflow: hidden;
68
79
  text-overflow: ellipsis;
@@ -99,12 +110,27 @@ adw-view-switcher {
99
110
  }
100
111
  }
101
112
 
113
+ // AdwViewSwitcherButton is a `.flat` GtkToggleButton
114
+ // (adw-view-switcher-button.ui:5-7), so its five states are
115
+ // `%button_basic_flat`'s currentColor alpha ladder, NOT the filled-button
116
+ // tokens: hover 7% / pressed 16%, and checked 10% / 13% / 19%
117
+ // (_buttons.scss:250-269 over _colors.scss:268-272). The same ladder the
118
+ // tab bar and the sidebar rows already spell out.
119
+ //
120
+ // It used to spend the FILLED-button tokens here: `--button-active-color`
121
+ // (.19 light / .22 dark) for the selected tab AND for its hover, and
122
+ // `--button-hover-color` (.13 / .15) for an unselected hover. Two defects
123
+ // in one rule. The selected tab sat at the shade upstream keeps for HELD
124
+ // DOWN, so it read as permanently pressed; and pinning its hover to that
125
+ // same value left the selected tab answering the pointer with nothing.
126
+ // Those tokens are also a FIXED rgba, so unlike the ladder they do not
127
+ // follow the foreground of whatever bar the switcher was dropped into.
102
128
  &:hover {
103
- background-color: var(--button-hover-color);
129
+ background-color: color-mix(in srgb, currentColor 7%, transparent);
104
130
  }
105
131
 
106
132
  &:active {
107
- background-color: var(--button-active-color);
133
+ background-color: color-mix(in srgb, currentColor 16%, transparent);
108
134
  }
109
135
 
110
136
  &:focus-visible {
@@ -112,15 +138,17 @@ adw-view-switcher {
112
138
  outline-offset: -2px;
113
139
  }
114
140
 
115
- // The selected view — a neutral filled chip (libadwaita darkens the
116
- // whole button; the icon/label keep the normal foreground colour
117
- // rather than turning accent).
141
+ // The selected view: a neutral fill over the whole tab. libadwaita leaves
142
+ // the foreground alone here, so no accent recolouring of icon or label.
118
143
  &.active {
119
- background-color: var(--button-active-color);
120
- color: var(--window-fg-color);
144
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
121
145
 
122
146
  &:hover {
123
- background-color: var(--button-active-color);
147
+ background-color: color-mix(in srgb, currentColor 13%, transparent);
148
+ }
149
+
150
+ &:active {
151
+ background-color: color-mix(in srgb, currentColor 19%, transparent);
124
152
  }
125
153
  }
126
154
  }
@@ -141,8 +169,15 @@ adw-view-switcher {
141
169
  .adw-view-switcher-button {
142
170
  flex-direction: column;
143
171
  gap: 1px;
144
- padding-top: 4px;
145
- padding-bottom: 2px;
172
+ // The narrow box carries NO horizontal padding upstream: only
173
+ // `padding-top: 4px`, and the 3px per side plus the 2px below sit on
174
+ // the LABEL (_view-switcher.scss:15-24). Aggregated onto the button
175
+ // here because this port has no separate box node between button and
176
+ // label, which is the same geometry for every label at least as wide
177
+ // as the 16px icon. Overriding only the vertical halves left the wide
178
+ // policy's `2px 12px` standing, so a narrow tab spent 24px on side
179
+ // padding where libadwaita spends 6px.
180
+ padding: 4px 3px 2px;
146
181
  font-size: 0.75rem;
147
182
 
148
183
  .adw-view-switcher-label {
@@ -152,22 +187,47 @@ adw-view-switcher {
152
187
  }
153
188
  }
154
189
 
155
- // The paired content area showing the active page — a card-like surface
156
- // below the switcher (the web stand-in for the Adw.ViewStack body).
190
+ // The paired content area showing the active page: the Adw.ViewStack half of
191
+ // this composite, and it draws NOTHING. libadwaita's recommended arrangement
192
+ // is an AdwToolbarView whose top bar holds the switcher and whose `content`
193
+ // is the bare AdwViewStack (adw-view-switcher.c:41-73); no rule in
194
+ // `_view-switcher.scss`, `_toolbars.scss` or `_views.scss` frames that stack,
195
+ // and the standalone `adw-view-stack` element here says the same in its own
196
+ // words.
197
+ //
198
+ // It used to be a card: a 1px separator outline, a 12px radius, a 9px gap
199
+ // under the row and 12px of padding inside. On a page that gives the switcher
200
+ // its full width, that outline runs down the container's own left and right
201
+ // edges with its corners curling away from them, which reads as a broken
202
+ // frame rather than as a view. libadwaita has no line between the row and the
203
+ // content at all: a FLAT top bar (the default, adw-toolbar-view.c:731) has no
204
+ // background and no shade, and the only shading that ever appears is the
205
+ // scroller's undershoot, which `_scrolling.scss` owns per scroller for the
206
+ // reason `_toolbar_view.scss` records.
207
+ //
208
+ // `display: flex` is load-bearing, not decoration: this box is the stack, and
209
+ // `adw-view-stack` is a flex COLUMN for the same reason. Without it the box
210
+ // stays block-level, its child's `flex: 1 1 auto` below has no flex container
211
+ // to answer and is silently inert, and the visible page keeps stopping at its
212
+ // own text in a pane many times its height.
157
213
  .adw-view-switcher-content {
214
+ display: flex;
215
+ flex-direction: column;
158
216
  flex: 1 1 auto;
159
- margin-top: var(--spacing-s);
160
- padding: var(--spacing-m);
161
- background-color: var(--view-bg-color);
162
- border: 1px solid var(--separator-color);
163
- border-radius: var(--card-radius);
217
+ min-height: 0;
164
218
  }
165
219
 
220
+ // Mirrors `.adw-view-stack-child`: the visible page is handed the whole
221
+ // content area, which is what lets a status page centre itself in it the way
222
+ // AdwViewStack allocates to its visible child.
166
223
  .adw-view-switcher-page {
167
224
  display: none;
168
225
 
169
226
  &.active-view {
170
- display: block;
227
+ display: flex;
228
+ flex-direction: column;
229
+ flex: 1 1 auto;
230
+ min-height: 0;
171
231
  }
172
232
  }
173
233
  }
@@ -77,6 +77,7 @@
77
77
  @use 'password_entry_row';
78
78
  @use 'toast';
79
79
  @use 'toolbar_view';
80
+ @use 'scrolling';
80
81
  @use 'wrap_box';
81
82
  @use 'panes';
82
83
  @use 'overlay_split_view';
@@ -26,6 +26,9 @@ import {
26
26
 
27
27
  import { normalizeIconName } from '@gjsify/adwaita-core';
28
28
 
29
+ import { isIconAvailable } from './icon-registry.js';
30
+ import { fallbackMask, maskOf } from './icon-registry.spec.js';
31
+
29
32
  import type { AdwActionRow } from './elements/adw-action-row.js';
30
33
  import type { AdwSwitchRow } from './elements/adw-switch-row.js';
31
34
  import type { AdwWindowTitle } from './elements/adw-window-title.js';
@@ -259,15 +262,24 @@ export const AdwActionRowsTest = async () => {
259
262
  // The mask class is the NORMALIZED name — the generated classes never
260
263
  // carry `-symbolic`, so interpolating the raw one asks for a class that has
261
264
  // never existed and draws an empty box.
262
- if (vector.startIconVisible) {
263
- expect(icons[0]!.classList.contains(`adw-icon--${normalizeIconName(vector.startIconName)}`)).toBe(
264
- true,
265
- );
266
- }
267
- if (vector.endIconVisible) {
268
- expect(icons[1]!.classList.contains(`adw-icon--${normalizeIconName(vector.endIconName)}`)).toBe(
269
- true,
270
- );
265
+ //
266
+ // And the class is only HALF the answer. `external-link-symbolic` in these
267
+ // vectors is well formed, normalizes cleanly and gets its class — while no
268
+ // mask class of that name exists (it is in neither the Adwaita icon theme
269
+ // nor `@gjsify/adwaita-icons`), so it paints the image-missing glyph. This
270
+ // suite was green on that: the class-string assertion the icon work exists
271
+ // to end. `isIconAvailable` reads the live cascade, so it decides which of
272
+ // the two answers is right for THIS name instead of the test guessing.
273
+ for (const [index, declared, visible] of [
274
+ [0, vector.startIconName, vector.startIconVisible],
275
+ [1, vector.endIconName, vector.endIconVisible],
276
+ ] as const) {
277
+ if (!visible) continue;
278
+ const icon = icons[index]!;
279
+ const resolved = normalizeIconName(declared);
280
+ expect(icon.classList.contains(`adw-icon--${resolved}`)).toBe(true);
281
+ expect(maskOf(icon)).not.toBe('none');
282
+ expect(maskOf(icon) === fallbackMask()).toBe(!isIconAvailable(resolved));
271
283
  }
272
284
  host.remove();
273
285
  });
@@ -20,6 +20,7 @@ import {
20
20
  BUTTON_CONTENT_TEXT_VECTORS,
21
21
  } from '@gjsify/adwaita-core/conformance';
22
22
  import type { AdwButtonContent } from './elements/adw-button-content.js';
23
+ import { fallbackMask, maskOf } from './icon-registry.spec.js';
23
24
 
24
25
  /** Mount a button content inside a plain Adwaita button, the way the stories do. */
25
26
  function mount(attributes: Record<string, string> = {}): {
@@ -123,13 +124,17 @@ export const AdwButtonContentTest = async () => {
123
124
  // asked for `.adw-icon--folder-download-symbolic`, a class that has
124
125
  // never existed, and drew an empty 16px box. `<adw-icon>` applies
125
126
  // `normalizeIconName`, which also makes the `' '` row assertable
126
- // instead of skipped: a space is not one CSS token, so it draws
127
- // nothing rather than injecting a second class.
127
+ // instead of skipped: a space is not one CSS token, so it lands on
128
+ // `image-missing` — the missing icon that row's own rule records GTK
129
+ // drawing there, by lookup failure rather than by this branch.
128
130
  const maskName = normalizeIconName(resolved);
129
- expect(icon.classList.contains(`adw-icon--${maskName}`)).toBe(maskName !== '');
130
- expect([...icon.classList].filter((c) => c.startsWith('adw-icon--')).length).toBe(
131
- maskName === '' ? 0 : 1,
132
- );
131
+ const drawn = maskName === '' ? 'image-missing' : maskName;
132
+ expect(icon.classList.contains(`adw-icon--${drawn}`)).toBe(true);
133
+ expect([...icon.classList].filter((c) => c.startsWith('adw-icon--')).length).toBe(1);
134
+ // …and it DRAWS that. The class alone is what the original bug survived:
135
+ // applied correctly, to a name with no glyph behind it.
136
+ expect(maskOf(icon)).not.toBe('none');
137
+ expect(maskOf(icon) === fallbackMask()).toBe(drawn === 'image-missing');
133
138
  // The image node is never hidden — `gtk_widget_set_visible` is
134
139
  // called on the LABEL only.
135
140
  expect(icon.hidden).toBe(false);
@@ -9,7 +9,12 @@
9
9
  // element sets it as an inline style precisely so it stays overridable.
10
10
  import { describe, expect, it } from '@gjsify/unit';
11
11
 
12
- import { CAROUSEL_PAGE_AT_POSITION_VECTORS, CAROUSEL_PAGE_LIST_VECTORS } from '@gjsify/adwaita-core/conformance';
12
+ import type { CarouselPageMeasurement } from '@gjsify/adwaita-core';
13
+ import {
14
+ CAROUSEL_PAGE_ALLOCATION_VECTORS,
15
+ CAROUSEL_PAGE_AT_POSITION_VECTORS,
16
+ CAROUSEL_PAGE_LIST_VECTORS,
17
+ } from '@gjsify/adwaita-core/conformance';
13
18
  import type { CarouselPageOp } from '@gjsify/adwaita-core/conformance';
14
19
 
15
20
  /** The carousel surface this suite drives — the element's own public API. */
@@ -62,6 +67,45 @@ function mount(labels: readonly string[], attributes: Record<string, string> = {
62
67
  return { carousel, host, track };
63
68
  }
64
69
 
70
+ /**
71
+ * One page that MEASURES what a conformance row says it does. `expand` is spelled
72
+ * `width: 100%`, the CSS of hexpand; `flex: none` keeps the slot's own flex layout from
73
+ * shrinking a natural size that is wider than the carousel before it can be measured.
74
+ */
75
+ function sizedCard(page: CarouselPageMeasurement, index: number): HTMLElement {
76
+ const element = document.createElement('div');
77
+ element.dataset.label = `p${index}`;
78
+ const width = page.expand ? '100%' : `${page.natural}px`;
79
+ element.style.cssText = `flex:none;height:100%;width:${width};`;
80
+ return element;
81
+ }
82
+
83
+ /** Mount a carousel `available` px wide whose pages measure what `pages` says. */
84
+ function mountSized(available: number, pages: readonly CarouselPageMeasurement[]) {
85
+ const host = document.createElement('div');
86
+ host.style.cssText = `width:${available}px;height:80px;`;
87
+ document.body.appendChild(host);
88
+
89
+ const carousel = document.createElement('adw-carousel') as CarouselElement;
90
+ carousel.style.cssText = `display:block;width:${available}px;height:80px;`;
91
+ pages.forEach((page, index) => carousel.appendChild(sizedCard(page, index)));
92
+ host.appendChild(carousel);
93
+
94
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
95
+ // Instant scrolls; snapping stays ON here, because where a page SNAPS is half of
96
+ // what these tests are about.
97
+ track.style.setProperty('scroll-behavior', 'auto');
98
+ return { carousel, host, track };
99
+ }
100
+
101
+ /** Each page's left edge relative to the carousel, rounded off the subpixel noise. */
102
+ function pageEdges(carousel: HTMLElement): number[] {
103
+ const origin = carousel.getBoundingClientRect().left;
104
+ return Array.from(carousel.querySelectorAll('.adw-carousel-page')).map((slot) =>
105
+ Math.round(slot.getBoundingClientRect().left - origin),
106
+ );
107
+ }
108
+
65
109
  /** The page labels in DOM order — what the user actually sees, not what the model says. */
66
110
  function domOrder(carousel: HTMLElement): string[] {
67
111
  return Array.from(carousel.querySelectorAll('.adw-carousel-page')).map(
@@ -159,6 +203,102 @@ export const AdwCarouselTest = async () => {
159
203
  }
160
204
  });
161
205
 
206
+ await describe('adw-carousel page allocation (libadwaita conformance vectors)', async () => {
207
+ // The rows the DOM can express. The element measures a RENDERED width, so a page
208
+ // whose MINIMUM exceeds the carousel has already been resolved by CSS before the
209
+ // element sees it, and a carousel with no pages has nothing to measure at all.
210
+ const measurable = CAROUSEL_PAGE_ALLOCATION_VECTORS.filter(
211
+ (vector) => vector.pages.length > 0 && vector.pages.every((page) => page.minimum === undefined),
212
+ );
213
+
214
+ for (const vector of measurable) {
215
+ await it(vector.rule, async () => {
216
+ const { carousel, host, track } = mountSized(vector.available, vector.pages);
217
+ await flush();
218
+
219
+ const widths = Array.from(carousel.querySelectorAll('.adw-carousel-page')).map((slot) =>
220
+ Math.round(slot.getBoundingClientRect().width),
221
+ );
222
+ expect(widths).toStrictEqual(vector.pages.map(() => vector.pageSize));
223
+ // The half-gap is the track's own padding, which is what lets the page
224
+ // strip start inside the carousel instead of flush against its edge.
225
+ expect(Math.round(Number.parseFloat(getComputedStyle(track).paddingInlineStart))).toBe(
226
+ vector.leadingInset,
227
+ );
228
+ host.remove();
229
+ });
230
+ }
231
+
232
+ await it('shows the previous and next page at the edges', async () => {
233
+ // THE REPORTED DEFECT. With `flex: 0 0 100%` the pages of the Carousel story
234
+ // (440px cards in a 480px carousel) filled the width and both neighbours sat
235
+ // entirely off screen, where GTK leaves 20px of each showing.
236
+ const pages: CarouselPageMeasurement[] = [{ natural: 440 }, { natural: 440 }, { natural: 440 }];
237
+ const { carousel, host } = mountSized(480, pages);
238
+ await flush();
239
+
240
+ // At rest on page 0: centred, with page 1 starting 20px before the right edge.
241
+ expect(pageEdges(carousel)).toStrictEqual([20, 460, 900]);
242
+
243
+ carousel.scrollToPage(1);
244
+ await flush();
245
+ // Page 1 centred, 20px of page 0 showing at the left and 20px of page 2 at the right.
246
+ expect(pageEdges(carousel)).toStrictEqual([-420, 20, 460]);
247
+ host.remove();
248
+ });
249
+
250
+ await it('leaves the LAST page room to centre as well', async () => {
251
+ // The trailing half-gap is track padding too. A scroll container that dropped
252
+ // its end padding would stop `leadingInset` short of the last snap point, and
253
+ // the last page would never reach the middle.
254
+ const pages: CarouselPageMeasurement[] = [{ natural: 440 }, { natural: 440 }, { natural: 440 }];
255
+ const { carousel, host, track } = mountSized(480, pages);
256
+ await flush();
257
+
258
+ carousel.scrollToPage(2);
259
+ await flush();
260
+ expect(Math.round(track.scrollLeft)).toBe(880);
261
+ expect(carousel.currentPage).toBe(2);
262
+ expect(pageEdges(carousel)).toStrictEqual([-860, -420, 20]);
263
+ host.remove();
264
+ });
265
+
266
+ await it('re-aims the scroll in flight instead of rewinding it', async () => {
267
+ // Mounted inside a HIDDEN ancestor, which is how real markup reaches this:
268
+ // `connectedCallback` measures 0, so the declared position waits for a
269
+ // layout, and revealing the host delivers the pending-position observer and
270
+ // the layout observer in ONE resize pass. The first fires a scroll to page
271
+ // 2 at the fallback pitch; the second narrows the pages and has to put the
272
+ // offset back, and `state.position` is still 0 there, because the `scroll`
273
+ // event that will teach it 2 fires a frame later. Restoring THAT parks the
274
+ // carousel on page 0 and swallows `position="2"`. GTK re-aims instead: it
275
+ // pushes the recomputed snap point into the running animation (:786-788).
276
+ //
277
+ // A carousel already laid out when it is mounted does NOT reach this: it
278
+ // allocates during `connectedCallback` and the pending observer never
279
+ // arms, which is why the mounted-visible spelling of this test passes
280
+ // either way and is not the one kept.
281
+ const host = document.createElement('div');
282
+ host.style.cssText = 'width:480px;height:80px;display:none;';
283
+ document.body.appendChild(host);
284
+
285
+ const carousel = document.createElement('adw-carousel') as CarouselElement;
286
+ carousel.style.cssText = 'display:block;width:480px;height:80px;';
287
+ carousel.setAttribute('position', '2');
288
+ for (let index = 0; index < 3; index++) carousel.appendChild(sizedCard({ natural: 440 }, index));
289
+ host.appendChild(carousel);
290
+ await flush();
291
+
292
+ host.style.display = 'block';
293
+ await flush();
294
+
295
+ const track = carousel.querySelector('.adw-carousel-track') as HTMLElement;
296
+ expect(Math.round(track.scrollLeft)).toBe(880);
297
+ expect(carousel.currentPage).toBe(2);
298
+ host.remove();
299
+ });
300
+ });
301
+
162
302
  await describe('adw-carousel page lookup (libadwaita conformance vectors)', async () => {
163
303
  for (const vector of CAROUSEL_PAGE_AT_POSITION_VECTORS) {
164
304
  // Only rows whose snap points are a settled page list are reachable through