@gemboss/ui 0.7.2 → 0.8.1

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/gemboss-ui.css CHANGED
@@ -1,8 +1,8 @@
1
1
  /*
2
2
  * GemBoss admin design system — GENERATED — do not edit; regenerate from gemboss:
3
3
  * node apps/admin/scripts/export-ui-css.mjs --out <path> (npm run export:ui-css)
4
- * gemboss commit: e66ca212d292e091348613340ba69cff77c159fc
5
- * sources sha256: 518302cf7e7a5abd3c1c2157f2b0993b9578c9e030d25ee90cd5ef36b898eec2
4
+ * gemboss commit: 22b5d0daf43ab644a6a9bffef08352a93cc5ca1c
5
+ * sources sha256: 7d836f2fc37705cee220b28af995f1e1006b2cb1a41e0ac162c29fa9276f70d3
6
6
  * contents: 1 allow-listed rule from apps/admin/src/app/globals.css + packages/ui/src/gemboss-tokens.css
7
7
  * (verbatim) + packages/ui/src/ui.css (verbatim) + packages/ui/src/gemboss-field.css (verbatim)
8
8
  * + 181 rules from packages/ui/src/shell.css (the shared shell)
@@ -457,10 +457,11 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
457
457
 
458
458
  /* ── Modal ────────────────────────────────────────────────────────────────────
459
459
  Rendered INLINE (not portaled): a portal escapes the .gemboss-admin-shell token
460
- scope and would render light. Kept in-tree so --ui-* resolves to the surface. */
460
+ scope and would render light. Kept in-tree so --ui-* resolves to the surface.
461
+ 0.7.3: Polaris Modal's density (#1099, README "Popup density"). Popup only: --ui-control-h stays 32. */
461
462
  .ui-overlay {
462
463
  position: fixed; inset: 0; z-index: 500;
463
- background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
464
+ background: rgba(0, 0, 0, 0.5);
464
465
  display: grid; place-items: center; padding: 20px;
465
466
  }
466
467
  .ui-modal {
@@ -470,30 +471,39 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
470
471
  }
471
472
  /* Head = title + the one close button every modal used to draw for itself. */
472
473
  .ui-modal__head {
473
- display: flex; align-items: flex-start; gap: 16px;
474
- padding: 16px 20px; border-bottom: 1px solid var(--ui-border);
475
- font-size: 15px; font-weight: 620; color: var(--ui-text);
474
+ display: flex; align-items: center; gap: 12px;
475
+ padding: 12px 16px; border-bottom: 1px solid var(--ui-border);
476
+ font-size: 14px; line-height: 20px; font-weight: 650; color: var(--ui-text);
476
477
  }
477
478
  .ui-modal__title { min-width: 0; flex: 1 1 auto; }
478
479
  .ui-modal__close {
479
480
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
480
- width: 44px; height: 44px; margin: -8px -8px -8px 0; /* a 44px target that does not grow the head */
481
+ width: 28px; height: 28px; padding: 0;
481
482
  border: 0; border-radius: var(--ui-radius-md, 8px); background: transparent;
482
483
  color: var(--ui-text-muted); cursor: pointer;
483
484
  }
484
485
  .ui-modal__close svg { width: 20px; height: 20px; }
485
486
  .ui-modal__close:hover { background: var(--ui-surface-2); color: var(--ui-text); }
486
487
  .ui-modal__close:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
488
+ /* Here, not in kit-plain.css: the admin loads only this file (0.7.3). */
489
+ .ui-modal__body { padding: 16px; }
490
+ .ui-modal__foot {
491
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
492
+ padding: 12px 16px; border-top: 1px solid var(--ui-border);
493
+ }
494
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { height: 28px; padding: 0 12px; font-size: var(--ui-fs-caption); }
495
+ /* 44px under a thumb or at phone width; -8px keeps the head 53. */
496
+ @media (pointer: coarse), (max-width: 47.9975em) {
497
+ .ui-modal__close { width: 44px; height: 44px; margin: -8px -8px -8px 0; }
498
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { min-height: 44px; }
499
+ }
487
500
 
488
- /* `size="lg"` — the screen-shaped dialog. Only the HOOK lives here: this sheet is imported by the
489
- root layout, so every rule in it is paid on every screen, and the geometry of a dialog with one
490
- consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
501
+ /* `size="lg"`: only the HOOK here (this sheet is paid on every screen); its geometry is in
502
+ SettingsDialog.module.css, the one consumer's chunk. */
491
503
  .ui-modal--lg { position: relative; }
492
504
 
493
- /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
494
- same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
495
- lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
496
- beside selects of a darker border and text fields of a third look (measured on prod). */
505
+ /* The kit's multi-line field is the ONE field family (01/10/2026, Chris: "form control đồng bộ"): same
506
+ border token, hover and focus ring as `gemboss-field` / `.ui-select` (it drew a third look on GemFactory). */
497
507
  .ui-textarea {
498
508
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
499
509
  background: var(--gemboss-field-bg);
@@ -2144,6 +2154,32 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
2144
2154
  /* A phone has no ⌘ key, and the box needs every pixel the bar has left. */
2145
2155
  @media (max-width: 460px) { .gemboss-topbar-search-kbd { display: none; } }
2146
2156
 
2157
+ /* ── Page width (0.8.1) ──────────────────────────────────────────────────────────────────────────────
2158
+ * Chris 04/10/2026: the rail on the left, and on the right a content column with ONE max width across every
2159
+ * service, not edge to edge. The admin's tiers (gemboss-shell.css, `.gemboss-w-*` around a Polaris Page), same
2160
+ * numbers; `page-width.test.tsx` fails if the two disagree:
2161
+ * .ui-page → --ui-content-max (900px, ui.css): lists, tables, card grids, dashboards
2162
+ * .ui-page--feed → 720px: read top to bottom (activity, logs, timelines)
2163
+ * .ui-page--form → 662px: one column of fields (Polaris narrowWidth)
2164
+ * .ui-page--canvas → 1440px: content measured to be wider than a reading column (admin: the funnel path)
2165
+ * .ui-page--full → no cap: a board or an editor that is the screen. Say why where you use it.
2166
+ * A cap only exists where it is SMALLER than the room it sits in. Right of the 240px rail with main's 24px padding,
2167
+ * 900 bites from 1188px up (1280 → 992 of room); 1440 bites only from ~1730px. Below that the column is simply the
2168
+ * room, so the tier never causes a horizontal scroll.
2169
+ * Here, not in ui.css: ui.css is exported verbatim into the admin under a 90 KB budget (89.8 KB at 0.7.3), and the
2170
+ * admin draws its tiers on Polaris' Page instead. Fixed px, not %: a % cap grows the line length with the monitor. */
2171
+ .ui-page {
2172
+ box-sizing: border-box;
2173
+ width: 100%;
2174
+ min-width: 0;
2175
+ max-width: var(--ui-content-max, 900px);
2176
+ margin-inline: auto;
2177
+ }
2178
+ .ui-page--feed { max-width: 720px; }
2179
+ .ui-page--form { max-width: 662px; }
2180
+ .ui-page--canvas { max-width: 1440px; }
2181
+ .ui-page--full { max-width: none; }
2182
+
2147
2183
  /* ═══ @gemboss/ui src/auth.css ═══ */
2148
2184
  /* The GemBoss sign-in screen — @gemboss/ui (28/09/2026).
2149
2185
  *
@@ -3376,23 +3412,24 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
3376
3412
  Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
3377
3413
  so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
3378
3414
  other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
3415
+ `.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
3379
3416
 
3380
3417
  Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
3381
3418
  <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
3382
3419
  cropped to fill; the card has no padding of its own, the body and foot do. */
3383
3420
  .ui-link,
3384
- .ui-prose a:not([class]) {
3421
+ .ui-prose :where(a:not([class])) {
3385
3422
  color: var(--gemboss-accent-strong, #3a44de);
3386
3423
  text-decoration: underline;
3387
3424
  text-decoration-thickness: from-font;
3388
3425
  text-underline-offset: 2px;
3389
3426
  }
3390
3427
  .ui-link:visited,
3391
- .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
3428
+ .ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
3392
3429
  .ui-link:hover,
3393
- .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
3430
+ .ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
3394
3431
  .ui-link:focus-visible,
3395
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
3432
+ .ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
3396
3433
 
3397
3434
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
3398
3435
  .ui-card__media {
@@ -3411,13 +3448,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
3411
3448
  }
3412
3449
 
3413
3450
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
3414
- ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
3415
- (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
3416
- .ui-modal__body { padding: 16px 20px; }
3417
- .ui-modal__foot {
3418
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
3419
- padding: 12px 20px; border-top: 1px solid var(--ui-border);
3420
- }
3451
+ Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
3452
+ exactly this shape. What stays here is what only the plain-HTML popups use:
3453
+
3454
+ A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
3455
+ `<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
3456
+ </div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
3457
+ .ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
3458
+ .ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
3459
+ /* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
3460
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
3421
3461
  /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
3422
3462
  (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
3423
3463
  .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
@@ -3689,7 +3729,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
3689
3729
  <div class="ui-modal__foot">…</div></dialog>` */
3690
3730
  dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
3691
3731
  dialog.ui-modal[open] { display: flex; flex-direction: column; }
3692
- dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
3732
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
3693
3733
 
3694
3734
  /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
3695
3735
 
@@ -3751,6 +3791,21 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
3751
3791
  repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
3752
3792
  .ui-metric.ui-metric:is([aria-pressed="true"], [aria-current="page"]) { border-color: var(--gemboss-accent); box-shadow: inset 0 0 0 1px var(--gemboss-accent); }
3753
3793
 
3794
+ /* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
3795
+ README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
3796
+ .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
3797
+ .ui-pager > a[aria-current="page"] { background: var(--ui-text); border-color: var(--ui-text); color: var(--ui-surface); font-size: var(--ui-fs-body); font-weight: 600; }
3798
+ .ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
3799
+ .ui-pager > [aria-disabled="true"] { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text-muted); font-size: var(--ui-fs-body); opacity: .55; cursor: default; }
3800
+ .ui-pager > [aria-disabled="true"]:hover { background: none; }
3801
+ .ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
3802
+ .ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
3803
+ .ui-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; padding: 16px 0; color: var(--ui-text-muted); font-size: var(--ui-fs-body); text-align: center; }
3804
+ .ui-more[hidden] { display: none; }
3805
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
3806
+ /* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
3807
+ @media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
3808
+
3754
3809
  /* ═══ @gemboss/ui src/chat.css ═══ */
3755
3810
  /*
3756
3811
  * The chat BODY — what an answer looks like, wherever a GemBoss surface puts a chat (01/10/2026).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/ui",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "GemBoss design system for every surface in the fleet: the admin shell (topbar + rail + drawer) as React components, and the admin stylesheet exported as one CSS file.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/app-shell.css CHANGED
@@ -139,3 +139,29 @@
139
139
  @media (pointer: coarse), (max-width: 47.9975em) { .gemboss-topbar-search { height: 44px; } }
140
140
  /* A phone has no ⌘ key, and the box needs every pixel the bar has left. */
141
141
  @media (max-width: 460px) { .gemboss-topbar-search-kbd { display: none; } }
142
+
143
+ /* ── Page width (0.8.1) ──────────────────────────────────────────────────────────────────────────────
144
+ * Chris 04/10/2026: the rail on the left, and on the right a content column with ONE max width across every
145
+ * service, not edge to edge. The admin's tiers (gemboss-shell.css, `.gemboss-w-*` around a Polaris Page), same
146
+ * numbers; `page-width.test.tsx` fails if the two disagree:
147
+ * .ui-page → --ui-content-max (900px, ui.css): lists, tables, card grids, dashboards
148
+ * .ui-page--feed → 720px: read top to bottom (activity, logs, timelines)
149
+ * .ui-page--form → 662px: one column of fields (Polaris narrowWidth)
150
+ * .ui-page--canvas → 1440px: content measured to be wider than a reading column (admin: the funnel path)
151
+ * .ui-page--full → no cap: a board or an editor that is the screen. Say why where you use it.
152
+ * A cap only exists where it is SMALLER than the room it sits in. Right of the 240px rail with main's 24px padding,
153
+ * 900 bites from 1188px up (1280 → 992 of room); 1440 bites only from ~1730px. Below that the column is simply the
154
+ * room, so the tier never causes a horizontal scroll.
155
+ * Here, not in ui.css: ui.css is exported verbatim into the admin under a 90 KB budget (89.8 KB at 0.7.3), and the
156
+ * admin draws its tiers on Polaris' Page instead. Fixed px, not %: a % cap grows the line length with the monitor. */
157
+ .ui-page {
158
+ box-sizing: border-box;
159
+ width: 100%;
160
+ min-width: 0;
161
+ max-width: var(--ui-content-max, 900px);
162
+ margin-inline: auto;
163
+ }
164
+ .ui-page--feed { max-width: 720px; }
165
+ .ui-page--form { max-width: 662px; }
166
+ .ui-page--canvas { max-width: 1440px; }
167
+ .ui-page--full { max-width: none; }
@@ -132,25 +132,18 @@ export function clampDockHeight(height: number, viewport: number): number {
132
132
  }
133
133
 
134
134
  /**
135
- * How far below the topbar a popup that has just OPENED starts (03/10/2026).
135
+ * The height a popup opens at: what the seller dragged it to, else AS TALL AS THE WINDOW ALLOWS — the same 12px under
136
+ * the topbar a drag can reach (`clampDockHeight`).
136
137
  *
137
- * The 12px ceiling above is where a popup the seller DRAGS can reach. It is not where one should open.
138
- * Chris, 03/10: "default height cao hơn sát vào topbar". Measured on his browser (window 620px tall):
139
- * gemwatcher had a dragged 599 saved, every reopen clamped it to 552, and the popup opened 12px under
140
- * the topbar; a flat 480 default also leaves only 84px on a 620 window and ~0 on a 560 one.
138
+ * 04/10/2026, Chris chose "gần hết màn" over min(480, viewport − 176) (03/10's 120px opening gap). Measured on
139
+ * gemwatcher at 1440×900 before: the popup was 480 tall, the engine card took ~190px of its 431px body and the
140
+ * conversation got ~90px. `null` means "no drag saved" and is resolved against the CURRENT window every time — the
141
+ * provider keeps it as null rather than freezing the mount-time ceiling (review #3744: a window made taller after load
142
+ * kept the old, shorter popup).
141
143
  */
142
- export const DOCK_OPEN_GAP = 120;
143
-
144
- /** The tallest a popup OPENS at in this window: a quarter-ish band of page stays visible above it. */
145
- export function dockOpenCeiling(viewport: number): number {
146
- return Math.max(DOCK_MIN_HEIGHT, Math.round(viewport) - DOCK_TOPBAR_HEIGHT - DOCK_OPEN_GAP);
147
- }
148
-
149
- /** The height a popup opens at: what the seller asked for (or the default), never closer to the topbar
150
- * than DOCK_OPEN_GAP. Dragging in-session can still go up to the 12px ceiling of `clampDockHeight`. */
151
144
  export function openingDockHeight(requested: number | null, viewport: number): number {
152
- const want = requested !== null && Number.isFinite(requested) && requested > 0 ? requested : DOCK_DEFAULT_HEIGHT;
153
- return clampDockHeight(Math.min(want, dockOpenCeiling(viewport)), viewport);
145
+ const want = requested !== null && Number.isFinite(requested) && requested > 0 ? requested : Infinity;
146
+ return clampDockHeight(want, viewport);
154
147
  }
155
148
 
156
149
  export const initialAssistantDockState: AssistantDockState = {
@@ -162,7 +155,10 @@ export const initialAssistantDockState: AssistantDockState = {
162
155
  dockOffsetRight: DOCK_DEFAULT_OFFSET_RIGHT,
163
156
  openNonce: 0,
164
157
  sideWidth: SIDE_DEFAULT_WIDTH,
165
- floatShape: "dock",
158
+ // The side panel is where every surface opens by default (Chris, 04/10/2026: "Dính cạnh phải"): it takes the full
159
+ // height and the page moves over instead of being covered. A host that does not offer it still gets the popup
160
+ // (`backTo`), and a seller who floats it as a popup is remembered as such.
161
+ floatShape: "side",
166
162
  sideAllowed: false,
167
163
  };
168
164
 
@@ -204,7 +200,12 @@ export function assistantDockReducer(state: AssistantDockState, event: Assistant
204
200
  case "side-allowed":
205
201
  if (event.allowed === state.sideAllowed) return state;
206
202
  // Withdrawn while the panel is up: it becomes the popup rather than a shape the host no longer draws for.
207
- return { ...state, sideAllowed: event.allowed, presentation: !event.allowed && state.presentation === "side" ? "dock" : state.presentation };
203
+ if (!event.allowed) return { ...state, sideAllowed: false, presentation: state.presentation === "side" ? "dock" : state.presentation };
204
+ // Allowed (again): a popup that is only there because the panel was not allowed a moment ago — the working shape
205
+ // is still "side" — goes back to the panel. Two ways that happens (review #3744): the admin mounts its host only
206
+ // after the first open, so that first open arrived before the host said yes; and a window that dipped below
207
+ // 1100px (DevTools docked, a snap) and came back. A popup the person CHOSE has floatShape "dock" and stays.
208
+ return { ...state, sideAllowed: true, presentation: state.presentation === "dock" && state.floatShape === "side" ? "side" : state.presentation };
208
209
 
209
210
  case "restore-float":
210
211
  return { ...state, floatShape: event.shape };
@@ -12,7 +12,7 @@
12
12
  * assistant is unmounted by the next menu click, and unmounting aborts the run. Geometry only changes
13
13
  * class names; the subtree is never re-parented (that would remount the chat and abort the run too).
14
14
  */
15
- import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
15
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
16
16
 
17
17
  import { DOCK_MIN_HEIGHT, SIDE_MIN_WIDTH, clampDockHeight, clampSideWidth } from "./dock";
18
18
  import { sideAvailable, useAssistant } from "./provider";
@@ -62,9 +62,33 @@ export interface AssistantSecondTab {
62
62
  content: ReactNode | null;
63
63
  }
64
64
 
65
+ /** Where the side panel stands BESIDE the page (assistant.css). Below it the panel would cover the page — the whole
66
+ * content area at 761–1099px, the whole screen on a phone — so the default there stays the popup (review #3744: on a
67
+ * phone a fresh open became a full-screen sheet over the very screen the question is about). */
68
+ export const SIDE_FITS_QUERY = "(min-width: 1100px)";
69
+
70
+ /** Before paint in the browser, a plain effect on the server (where a layout effect only warns). */
71
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
72
+
73
+ function useSideFits(): boolean {
74
+ const [fits, setFits] = useState(() => typeof window === "undefined" || !window.matchMedia
75
+ ? true : window.matchMedia(SIDE_FITS_QUERY).matches);
76
+ useEffect(() => {
77
+ if (typeof window === "undefined" || !window.matchMedia) return;
78
+ const mq = window.matchMedia(SIDE_FITS_QUERY);
79
+ const on = () => setFits(mq.matches);
80
+ on();
81
+ // Safari < 14 has only the old listener API on MediaQueryList.
82
+ if (mq.addEventListener) mq.addEventListener("change", on);
83
+ else mq.addListener(on);
84
+ return () => { if (mq.removeEventListener) mq.removeEventListener("change", on); else mq.removeListener(on); };
85
+ }, []);
86
+ return fits;
87
+ }
88
+
65
89
  export function AssistantHost({
66
90
  title, labels, onNewChat, onExpand, onShrink, fullActions, secondTab, view = "primary", onViewChange,
67
- miniHint, allowSide = false, children,
91
+ miniHint, allowSide = true, children,
68
92
  }: {
69
93
  /** "Gem assistant" in the admin. Names the region, the popup header and the status bar. */
70
94
  title: string;
@@ -86,7 +110,8 @@ export function AssistantHost({
86
110
  /**
87
111
  * Offer the full-height SIDE panel (01/10/2026): a "Pin to the side" button in the popup, "Float as a popup" in the
88
112
  * panel. The page makes room for it — `.gemboss-platform-main` pads by `--gb-assistant-side-w`; a surface with its
89
- * own layout reads the same variable. Off by default, so a surface that has not laid out for it never meets it.
113
+ * own layout reads the same variable. ON by default since 04/10/2026 (Chris: the side panel is every surface's default
114
+ * shape); a surface whose layout cannot make room passes `allowSide={false}` and gets the popup instead.
90
115
  */
91
116
  allowSide?: boolean;
92
117
  children?: ReactNode;
@@ -192,8 +217,16 @@ export function AssistantHost({
192
217
  }, []);
193
218
 
194
219
  // The provider gates every way into the side panel on this, so a host that never offers it never draws it.
220
+ // …and neither does a window where the panel cannot stand beside the page (04/10/2026). Below 1100 it covers the
221
+ // content area (or the screen) and the popup hides its pin (assistant.css); with the side panel now the DEFAULT shape,
222
+ // a first open at 768 went straight into that covering panel (measured on gemwatcher: 528px wide over the whole
223
+ // page). There the person gets the popup; the reducer puts the panel back when the window is wide again.
195
224
  const { setSideAllowed } = dock;
196
- useEffect(() => { setSideAllowed(allowSide); }, [allowSide, setSideAllowed]);
225
+ const sideFits = useSideFits();
226
+ // Before paint: the admin mounts this host only after the first open, so that open was decided while the panel was
227
+ // not yet allowed; registering after paint drew the stand-in popup for a frame before the reducer swapped in the
228
+ // panel (review #3744).
229
+ useBeforePaint(() => { setSideAllowed(allowSide && sideFits); }, [allowSide, sideFits, setSideAllowed]);
197
230
 
198
231
  // ── Width drag (side only) ───────────────────────────────────────────────
199
232
  // The panel's LEFT edge is a `.ui-resizer` (the fleet's one seam, @gemboss/ui ui.css): dragging it left widens the
@@ -371,7 +404,9 @@ export function AssistantHost({
371
404
  <span className="gb-dock-sr">{L.newChat}</span>
372
405
  </button>
373
406
  ) : null}
374
- {isDock && allowSide ? (
407
+ {/* On `dock.sideAllowed`, not the prop: it already folds in the window width (SIDE_FITS_QUERY), so the pin
408
+ never shows where pressing it would only drop the working shape to "dock" (review #3744). */}
409
+ {isDock && dock.sideAllowed ? (
375
410
  <button type="button" className="gb-dock-icon gb-dock-action gb-dock-pinside" title={L.pinSide} onClick={dock.openSide}>
376
411
  {/* A window with its right third filled: the panel it will become. */}
377
412
  <svg viewBox="0 0 20 20" aria-hidden>
@@ -16,9 +16,9 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR
16
16
  import {
17
17
  assistantDockReducer,
18
18
  initialAssistantDockState,
19
- DOCK_DEFAULT_HEIGHT,
20
19
  DOCK_DEFAULT_OFFSET_RIGHT,
21
20
  openingDockHeight,
21
+ type AssistantFloatShape,
22
22
  SIDE_DEFAULT_WIDTH,
23
23
  type AssistantDockState,
24
24
  } from "./dock";
@@ -33,7 +33,9 @@ export interface AssistantOptions {
33
33
 
34
34
  /** Storage keys. `lastMode` is the last shape the seller actually used: "full", "dock" or "side" — nothing
35
35
  * else is worth reopening as. Module-level so the callbacks below stay identity-stable. */
36
- const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode" | "sideWidth" | "floatShape") => `${prefix}.${k}`;
36
+ // `shape`, not the old `floatShape` key (04/10/2026): that one was written on every mount with the then-default "dock",
37
+ // so it records what every browser was GIVEN, not what anyone chose — reading it would keep the old default forever.
38
+ const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode" | "sideWidth" | "shape") => `${prefix}.${k}`;
37
39
 
38
40
  /**
39
41
  * The width of the content area the side panel sits in: the window minus the rail. Measured from `main`'s LEFT
@@ -117,11 +119,10 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
117
119
  useEffect(() => {
118
120
  try {
119
121
  const stored = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockHeight"));
120
- // Opens no closer to the topbar than DOCK_OPEN_GAP, even when a past drag saved the ceiling
121
- // (dock.ts, 03/10/2026). The stored number is left as it was: a drag rewrites it.
122
- const height = openingDockHeight(stored === null || stored === "" ? null : Number(stored), window.innerHeight);
123
- requestedHeight.current = height;
124
- dispatch({ type: "resize", height, viewport: window.innerHeight });
122
+ // No drag saved: null, resolved against the CURRENT window on every resize (dock.ts `openingDockHeight`).
123
+ const raw = stored === null || stored === "" ? NaN : Number(stored);
124
+ requestedHeight.current = Number.isFinite(raw) && raw > 0 ? raw : null;
125
+ dispatch({ type: "resize", height: openingDockHeight(requestedHeight.current, window.innerHeight), viewport: window.innerHeight });
125
126
  // `Number(null)` is 0, not NaN — so reading a key that was never written used to slide the
126
127
  // popup flush against the window edge on every fresh browser. Measured: inline `right: 0px`.
127
128
  const storedOffset = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockOffsetRight"));
@@ -133,7 +134,10 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
133
134
  dispatch({ type: "slide", offsetRight: rawOffset, ...offsetBounds() });
134
135
  }
135
136
  // The working shape outlives a reload: `lastMode` cannot stand in for it, since opening full screen overwrites it.
136
- if (window.localStorage.getItem(storageKey(opts.current.storagePrefix, "floatShape")) === "side") dispatch({ type: "restore-float", shape: "side" });
137
+ const shape = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "shape"));
138
+ if (shape === "side" || shape === "dock") dispatch({ type: "restore-float", shape });
139
+ // The pre-0.8.0 key: never read again (it holds the old default, not a choice) — drop it so storage has one answer.
140
+ window.localStorage.removeItem(`${opts.current.storagePrefix}.floatShape`);
137
141
  const storedSide = Number(window.localStorage.getItem(storageKey(opts.current.storagePrefix, "sideWidth")));
138
142
  if (Number.isFinite(storedSide) && storedSide > 0) {
139
143
  requestedSide.current = storedSide;
@@ -149,7 +153,7 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
149
153
  * window baked the smaller number in and growing it back never recovered. Measured 2026-09-12:
150
154
  * 800 → window 600 → 512 → window 1200 → still 512. Same trap the persisted value already avoids.
151
155
  */
152
- const requestedHeight = useRef(DOCK_DEFAULT_HEIGHT);
156
+ const requestedHeight = useRef<number | null>(null);
153
157
  /** Same reasoning as `requestedHeight`: persist what the seller ASKED for, clamp only to draw. */
154
158
  const requestedOffset = useRef(DOCK_DEFAULT_OFFSET_RIGHT);
155
159
  /** …and the same for the side panel's width: a width chosen on a 27" screen comes back there. */
@@ -175,7 +179,7 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
175
179
  // got taller again must give the seller their height back.
176
180
  useEffect(() => {
177
181
  const onResize = () => {
178
- dispatch({ type: "resize", height: requestedHeight.current, viewport: window.innerHeight });
182
+ dispatch({ type: "resize", height: openingDockHeight(requestedHeight.current, window.innerHeight), viewport: window.innerHeight });
179
183
  // …and sideways too. `requestedOffset` was being written and never read, so a window that got
180
184
  // narrower left the popup where a wider one had allowed it: on the rail, or past the edge.
181
185
  dispatch({ type: "slide", offsetRight: requestedOffset.current, ...offsetBounds() });
@@ -187,11 +191,18 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
187
191
 
188
192
  const setDockHeight = useCallback((height: number) => {
189
193
  const viewport = typeof window === "undefined" ? 900 : window.innerHeight;
190
- requestedHeight.current = Math.round(height);
194
+ // Dragged (or Home) to the ceiling = "as tall as the window allows": back to AUTO, not this window's number — a
195
+ // saved ceiling kept the popup at 632 after a 700px window was maximised (review #3744).
196
+ const auto = Math.round(height) >= openingDockHeight(null, viewport);
197
+ requestedHeight.current = auto ? null : Math.round(height);
191
198
  dispatch({ type: "resize", height, viewport });
192
199
  // The RAW request, not the clamped result: dragging to the ceiling in a short window used to
193
200
  // save that ceiling, so the popup came back permanently smaller on a tall screen.
194
- try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "dockHeight"), String(Math.round(height))); } catch { /* best-effort */ }
201
+ try {
202
+ const k = storageKey(opts.current.storagePrefix, "dockHeight");
203
+ if (auto) window.localStorage.removeItem(k);
204
+ else window.localStorage.setItem(k, String(Math.round(height)));
205
+ } catch { /* best-effort */ }
195
206
  }, []);
196
207
 
197
208
  const setSideWidth = useCallback((width: number) => {
@@ -216,9 +227,15 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
216
227
  * Only `full`, `dock` and `side` are recorded. `mini` and `hidden` are ways of putting something away, not
217
228
  * ways of working in it; reopening into a collapsed bar would answer a click with almost nothing.
218
229
  */
219
- useEffect(() => {
220
- try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "floatShape"), state.floatShape); } catch { /* best-effort */ }
221
- }, [state.floatShape]);
230
+ // The working shape is written ONLY when the person picks it (`openDock` / `openSide` below — the popup's pin and the
231
+ // panel's "Float as a popup"). An effect on `floatShape` wrote the initial default on every mount, i.e. what each
232
+ // browser was GIVEN (the flaw that retired the old `floatShape` key), and under StrictMode's double effect run that
233
+ // write landed between two restores and turned a saved "dock" back into "side" on every reload (review #3744).
234
+ const sideAllowedNow = useRef(false);
235
+ sideAllowedNow.current = state.sideAllowed;
236
+ const saveShape = useCallback((shape: AssistantFloatShape) => {
237
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "shape"), shape); } catch { /* best-effort */ }
238
+ }, []);
222
239
 
223
240
  useEffect(() => {
224
241
  if (state.presentation !== "full" && state.presentation !== "dock" && state.presentation !== "side") return;
@@ -244,10 +261,16 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
244
261
  if (opts.current.isOnFullPage()) return;
245
262
  let last = "";
246
263
  try { last = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "lastMode")) ?? ""; } catch { /* private mode */ }
247
- if (last !== "dock" && last !== "side") return;
264
+ // The RAIL's Assistant item (`.gnav-assistant`): only someone who last worked FULL screen follows it. Everyone
265
+ // else — including a browser that never opened it — gets the working shape back (`floatShape`, the side panel by
266
+ // default since 04/10/2026), not the `lastMode` "dock" the old default wrote for everybody.
267
+ // Any OTHER link to the full chat ("Open in chat" in a page's content) keeps the old rule: it navigates unless
268
+ // the person last worked in the popup or the panel (review #3744: it was swallowed for every fresh browser).
269
+ const rail = !!anchor.closest(".gnav-assistant");
270
+ if (rail ? last === "full" : last !== "dock" && last !== "side") return;
248
271
  e.preventDefault();
249
272
  e.stopPropagation();
250
- dispatch({ type: last });
273
+ dispatch({ type: "reopen" });
251
274
  // The shell closes its mobile nav drawer when the ROUTE changes. This click deliberately does
252
275
  // not change the route, so on a phone the drawer stayed open on top of the popup it had just
253
276
  // opened — measured at 390px, `.gnav-scroll` sitting over the composer. Say it out loud
@@ -295,8 +318,9 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
295
318
  // fire a spurious "the run ended" on every state change.
296
319
  const enterFull = useCallback(() => dispatch({ type: "enter-full" }), []);
297
320
  const leaveFull = useCallback(() => dispatch({ type: "leave-full" }), []);
298
- const openDock = useCallback(() => dispatch({ type: "dock" }), []);
299
- const openSide = useCallback(() => dispatch({ type: "side" }), []);
321
+ const openDock = useCallback(() => { dispatch({ type: "dock" }); saveShape("dock"); }, [saveShape]);
322
+ // Not offered here (narrow window, host without the panel): the reducer gives the popup and nothing is recorded.
323
+ const openSide = useCallback(() => { dispatch({ type: "side" }); if (sideAllowedNow.current) saveShape("side"); }, [saveShape]);
300
324
  const reopen = useCallback(() => dispatch({ type: "reopen" }), []);
301
325
  const setSideAllowed = useCallback((allowed: boolean) => dispatch({ type: "side-allowed", allowed }), []);
302
326
  const minimize = useCallback(() => dispatch({ type: "minimize" }), []);
package/src/index.ts CHANGED
@@ -2,6 +2,9 @@ export {
2
2
  AppShell, MobileMenuButton, MobileNavBackdrop, Rail, RailBottom, RailCount, RailGroup, RailHeading,
3
3
  RailItem, RailScroll, RailSection, RailSep, RailSub, RailSubItem, Shell, ShellMain, Topbar,
4
4
  } from "./shell";
5
+ // Page width (0.8.1): its own plain module, so nothing the admin imports from shell.tsx carries it (first-load budget).
6
+ export { PageContainer, pageWidthClass } from "./page";
7
+ export type { PageWidth } from "./page";
5
8
  export { GemMark } from "./GemMark";
6
9
  export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
7
10
  export { BrandLockup } from "./brand";
package/src/kit-plain.css CHANGED
@@ -61,23 +61,24 @@
61
61
  Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
62
62
  so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
63
63
  other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
64
+ `.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
64
65
 
65
66
  Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
66
67
  <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
67
68
  cropped to fill; the card has no padding of its own, the body and foot do. */
68
69
  .ui-link,
69
- .ui-prose a:not([class]) {
70
+ .ui-prose :where(a:not([class])) {
70
71
  color: var(--gemboss-accent-strong, #3a44de);
71
72
  text-decoration: underline;
72
73
  text-decoration-thickness: from-font;
73
74
  text-underline-offset: 2px;
74
75
  }
75
76
  .ui-link:visited,
76
- .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
+ .ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
78
  .ui-link:hover,
78
- .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
+ .ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
80
  .ui-link:focus-visible,
80
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
+ .ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
82
 
82
83
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
83
84
  .ui-card__media {
@@ -96,13 +97,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
96
97
  }
97
98
 
98
99
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
99
- ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
100
- (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
101
- .ui-modal__body { padding: 16px 20px; }
102
- .ui-modal__foot {
103
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
104
- padding: 12px 20px; border-top: 1px solid var(--ui-border);
105
- }
100
+ Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
101
+ exactly this shape. What stays here is what only the plain-HTML popups use:
102
+
103
+ A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
104
+ `<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
105
+ </div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
106
+ .ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
107
+ .ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
108
+ /* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
109
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
106
110
  /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
107
111
  (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
108
112
  .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
@@ -374,7 +378,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
374
378
  <div class="ui-modal__foot">…</div></dialog>` */
375
379
  dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
376
380
  dialog.ui-modal[open] { display: flex; flex-direction: column; }
377
- dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
381
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
378
382
 
379
383
  /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
380
384
 
@@ -435,3 +439,18 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
435
439
  /* Class doubled: the chosen card is usually the one under the pointer, and `button.ui-metric:hover` (0,2,1) would
436
440
  repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
437
441
  .ui-metric.ui-metric:is([aria-pressed="true"], [aria-current="page"]) { border-color: var(--gemboss-accent); box-shadow: inset 0 0 0 1px var(--gemboss-accent); }
442
+
443
+ /* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
444
+ README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
445
+ .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
446
+ .ui-pager > a[aria-current="page"] { background: var(--ui-text); border-color: var(--ui-text); color: var(--ui-surface); font-size: var(--ui-fs-body); font-weight: 600; }
447
+ .ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
448
+ .ui-pager > [aria-disabled="true"] { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text-muted); font-size: var(--ui-fs-body); opacity: .55; cursor: default; }
449
+ .ui-pager > [aria-disabled="true"]:hover { background: none; }
450
+ .ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
451
+ .ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
452
+ .ui-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; padding: 16px 0; color: var(--ui-text-muted); font-size: var(--ui-fs-body); text-align: center; }
453
+ .ui-more[hidden] { display: none; }
454
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
455
+ /* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
456
+ @media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }