@gemboss/ui 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export {
5
5
  // Page width (0.8.1): its own plain module, so nothing the admin imports from shell.tsx carries it (first-load budget).
6
6
  export { PageContainer, pageWidthClass } from "./page";
7
7
  export type { PageWidth } from "./page";
8
+ // The loading boundary every route group under @gemboss/ui/next needs (0.10.0). Plain module, like ./page.
9
+ export { RouteCover, RouteHolding } from "./route-holding";
8
10
  export { GemMark } from "./GemMark";
9
11
  export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
10
12
  export { BrandLockup } from "./brand";
package/src/kit-plain.css CHANGED
@@ -40,10 +40,19 @@
40
40
  .ui-menu__sep { height: 1px; margin: var(--ui-space-1) 0; border: 0; background: var(--ui-border); }
41
41
  /* Checkbox: the LABEL is the target; disabled turns both the label and the box to not-allowed. Focus ring in the
42
42
  kit's own token (no --p-* here: this file is appended after the export's Polaris resolver runs). */
43
- .ui-check { display: inline-flex; align-items: center; gap: var(--ui-space-2); min-height: 44px; cursor: pointer; font-size: var(--ui-fs-body); color: var(--ui-text); }
43
+ .ui-check { display: inline-flex; align-items: center; gap: var(--ui-space-2); min-height: 44px; max-width: 100%; overflow-wrap: break-word; cursor: pointer; font-size: var(--ui-fs-body); color: var(--ui-text); }
44
44
  .ui-check > input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: inherit; }
45
45
  .ui-check > input:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
46
46
  .ui-check:has(> input:disabled) { opacity: .5; cursor: not-allowed; }
47
+ /* 0.9.0: a long run with no spaces (a URL, "clinical/premium/…/utility") breaks inside the label instead of widening
48
+ the page: gemwatcher Operations overflowed 69px at 320 on one switch description (#375). Three parts: the label is
49
+ capped at its parent (max-width above), its TEXT child may shrink (min-width 0, the last child only so a badge in
50
+ front keeps its width), and break-word breaks the run once it cannot fit a line.
51
+ break-word, NOT anywhere: anywhere also drops the label's min-content, and a flex row then squeezes a short label
52
+ ("Lock" beside a long band name in GemFactory) into "Lo/ck". Two cases stay the surface's job: the text must be an
53
+ element (`input + span`; bare text is an anonymous flex item min-width cannot reach), and in a flex ROW the label
54
+ keeps its min-content, so that row must wrap. */
55
+ .ui-check > :last-child:not(input) { min-width: 0; }
47
56
  .ui-label { display: block; margin: 0 0 var(--ui-space-1); font-size: var(--ui-fs-caption); font-weight: 600; color: var(--ui-text-muted); }
48
57
  .ui-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--ui-space-2) var(--ui-space-4); margin: 0; font-size: var(--ui-fs-body); }
49
58
  .ui-kv > dt { color: var(--ui-text-muted); }
@@ -310,12 +319,13 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
310
319
  }
311
320
 
312
321
  /* Radio: the checkbox's row with `type="radio"` — `<fieldset class="ui-choices"><legend class="ui-label">Plan</legend>
313
- <label class="ui-check"><input type="radio" name="plan" checked> Basic</label>…</fieldset>`. */
314
- .ui-choices { display: grid; gap: 0; margin: 0; padding: 0; border: 0; min-width: 0; }
322
+ <label class="ui-check"><input type="radio" name="plan" checked><span>Basic</span></label>…</fieldset>`.
323
+ One minmax(0, 1fr) column: an auto column grows to the longest unbroken word in any label (0.9.0). */
324
+ .ui-choices { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; margin: 0; padding: 0; border: 0; min-width: 0; }
315
325
  .ui-choices > legend { padding: 0; }
316
326
 
317
327
  /* Switch: a checkbox that reads as on / off, for a setting that applies at once (no Save button).
318
- `<label class="ui-check ui-switch"><input type="checkbox" role="switch" checked> Send Monday digest</label>`.
328
+ `<label class="ui-check ui-switch"><input type="checkbox" role="switch" checked><span>Send Monday digest</span></label>`.
319
329
  Track 32×20, thumb 16; the row stays the checkbox's 44px target. */
320
330
  .ui-switch > input[type="checkbox"] {
321
331
  appearance: none; -webkit-appearance: none; position: relative; width: 32px; height: 20px; margin: 0;
package/src/kit-plain.js CHANGED
@@ -16,6 +16,7 @@
16
16
  initShell(); // a hand-written AppShell: ☰ drawer, backdrop, Escape, followed link
17
17
  initPasswordFields(); // .gb-auth-pw-toggle reveal; resetPasswordFields() on sign-out
18
18
  initSearch(); // TopbarSearch: ⌘K / Ctrl+K and "/" focus the box, Escape leaves it
19
+ // initNav() runs by itself (0.10.0): a followed rail link shows a spinner, a rested-on link is prefetched
19
20
  */
20
21
  (function (g) {
21
22
  "use strict";
@@ -317,7 +318,123 @@
317
318
  });
318
319
  }
319
320
 
321
+ /* Navigation pending (0.10.0), the same behaviour as `@gemboss/ui/next` NavLink for a page with no router: a followed
322
+ rail link answers on the first frame (its icon becomes a spinner: `aria-busy="true"`, drawn by app-shell.css), and a
323
+ link the pointer or keyboard focus RESTS on for 120 ms, or a finger touches, is prefetched once (speculation rules in Chromium, else
324
+ `<link rel="prefetch">`; see navPrefetch), so the click that follows does not wait for the server. Runs by itself
325
+ when the script loads (delegated on the document, so links
326
+ rendered later are covered); `initNav()` is idempotent.
327
+ Which links: `.gnav-item` / `.gnav-subitem` anchors, and any `a[data-gb-pending]` ("inline" | "corner": a page link
328
+ that gets the small spinner). Same origin, a plain left click (no modifier key), no `target` other than _self, no
329
+ `download`, and not a jump to `#hash` on this same page. Opt out: `data-gb-nav="off"` on the link or any ancestor
330
+ (`<html>` for the whole page); `data-gb-prefetch="off"` keeps the spinner and skips the prefetch (a GET with an
331
+ effect, e.g. a sign-out link). A link marked `data-gb-nav="next"` belongs to NavLink and is left alone, and in a
332
+ Next app, or once a rail click was taken over by a client router, nothing is prefetched here (the router owns that).
333
+ The mark comes off on `pageshow` (back/forward cache: the page you return to must not still be "loading"), when the
334
+ navigation is cancelled (Escape / the browser's stop: the Navigation API's `navigateerror`), on the next followed
335
+ link, and after 15 s at the latest (a 204, a download served as a page link). */
336
+ var NAV_DWELL = 120, NAV_NET = 15000;
337
+ var SPEC_RULES = typeof HTMLScriptElement !== "undefined" && !!HTMLScriptElement.supports && HTMLScriptElement.supports("speculationrules");
338
+ var navOn = false, navTimer = 0, navNet = 0, navArmed = null, prefetched = {}, clientRouted = false;
339
+ function navLink(t) {
340
+ var a = t && t.closest ? t.closest("a[href]") : null;
341
+ if (!a || !(a.matches(".gnav-item, .gnav-subitem") || a.hasAttribute("data-gb-pending"))) return null;
342
+ if (a.getAttribute("data-gb-nav") === "next" || a.closest('[data-gb-nav="off"]')) return null;
343
+ if ((a.target && a.target !== "_self") || a.hasAttribute("download")) return null;
344
+ var u;
345
+ try { u = new URL(a.href, location.href); } catch (_) { return null; }
346
+ if (u.origin !== location.origin || !/^https?:$/.test(u.protocol)) return null;
347
+ // A #hash on this same page scrolls; nothing loads. `href="#"` too: its URL's .hash is "" (review #3777).
348
+ if (u.pathname === location.pathname && u.search === location.search && (u.hash || (a.getAttribute("href") || "").indexOf("#") > -1)) return null;
349
+ return a;
350
+ }
351
+ function navClear() {
352
+ clearTimeout(navNet);
353
+ document.querySelectorAll('[data-gb-busy]').forEach(function (a) {
354
+ a.removeAttribute("data-gb-busy");
355
+ a.removeAttribute("aria-busy");
356
+ var m = a.querySelector(":scope > .gb-pending-mark");
357
+ if (m) m.remove();
358
+ });
359
+ }
360
+ function navMark(a) {
361
+ navClear();
362
+ a.setAttribute("data-gb-busy", "");
363
+ a.setAttribute("aria-busy", "true");
364
+ if (a.hasAttribute("data-gb-pending")) {
365
+ var m = document.createElement("span");
366
+ m.className = "gb-pending-mark";
367
+ m.setAttribute("role", "status");
368
+ m.innerHTML = '<span class="ui-sr-only">' + esc(a.getAttribute("data-gb-pending-label") || "Loading") + "</span>";
369
+ a.appendChild(m);
370
+ }
371
+ navNet = setTimeout(navClear, NAV_NET);
372
+ }
373
+ function navPrefetch(a) {
374
+ // A page whose router owns its links fetches them itself: Next (detected), or any app seen taking a rail click over
375
+ // (preventDefault: react-router, a SPA). A document prefetch there is a server request the click never uses.
376
+ if (clientRouted || g.next || g.__next_f || g.__NEXT_DATA__ || a.closest('[data-gb-prefetch="off"]')) return;
377
+ var c = navigator.connection;
378
+ if (c && c.saveData) return;
379
+ var u = new URL(a.href, location.href);
380
+ u.hash = "";
381
+ var href = u.href;
382
+ if (prefetched[href] || (u.pathname === location.pathname && u.search === location.search)) return;
383
+ prefetched[href] = true;
384
+ // Speculation rules where the browser has them (Chromium): its prefetch is what the click then USES. A plain
385
+ // `<link rel="prefetch">` of a page is reused only when that page is cacheable (max-age > 0); a server-rendered page
386
+ // sends max-age=0 / no-cache, so the click fetched it a second time, and one still in flight made the click SLOWER
387
+ // (measured 05/10/2026 through a 280 ms proxy: 1121 ms to the new page against 737 ms with no prefetch at all).
388
+ // Speculation rules were reused under every Cache-Control tried. Other browsers get the link, which helps there
389
+ // only for cacheable pages.
390
+ var el;
391
+ if (SPEC_RULES) {
392
+ el = document.createElement("script");
393
+ el.type = "speculationrules";
394
+ el.textContent = JSON.stringify({ prefetch: [{ source: "list", urls: [href] }] });
395
+ } else {
396
+ el = document.createElement("link");
397
+ el.rel = "prefetch";
398
+ el.href = href;
399
+ }
400
+ el.setAttribute("data-gb-prefetched", "");
401
+ document.head.appendChild(el);
402
+ }
403
+ function initNav() {
404
+ // A bundled app also evaluates this file on the server (a client module is rendered there too): no document.
405
+ if (navOn || typeof document === "undefined") return;
406
+ navOn = true;
407
+ // On the WINDOW, bubble phase: the last stop of a click, after every element and document listener the page adds
408
+ // (this script loads early, so a document listener here would run before the page's own delegated handlers). A
409
+ // handler that took the click over (a client router, a popup) has called preventDefault by now.
410
+ addEventListener("click", function (e) {
411
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
412
+ var a = navLink(e.target);
413
+ if (!a) return;
414
+ if (e.defaultPrevented) { clientRouted = true; return; }
415
+ navMark(a);
416
+ });
417
+ // Pointer and keyboard focus both have to REST: a sweep down the rail, or Tab run down it, fetches nothing.
418
+ var arm = function (a) {
419
+ if (a === navArmed) return; // moving between the icon and the label of the same link is not a new rest
420
+ clearTimeout(navTimer);
421
+ navArmed = a;
422
+ if (a) navTimer = setTimeout(function () { if (navArmed === a) navPrefetch(a); }, NAV_DWELL);
423
+ };
424
+ // The focus a press gives a link is not keyboard intent: that click is already navigating.
425
+ var pressed = null;
426
+ document.addEventListener("mousedown", function (e) { pressed = navLink(e.target); if (pressed && pressed === navArmed) clearTimeout(navTimer); });
427
+ document.addEventListener("mouseover", function (e) { arm(navLink(e.target)); });
428
+ document.addEventListener("focusin", function (e) { var a = navLink(e.target); if (a && a === pressed) return; arm(a); });
429
+ // A touch is already deliberate (its click follows in ~100 ms).
430
+ document.addEventListener("touchstart", function (e) { var a = navLink(e.target); if (a) navPrefetch(a); }, { passive: true });
431
+ addEventListener("pageshow", navClear);
432
+ addEventListener("keydown", function (e) { if (e.key === "Escape") navClear(); });
433
+ if (g.navigation && g.navigation.addEventListener) g.navigation.addEventListener("navigateerror", navClear);
434
+ }
435
+ initNav();
436
+
320
437
  g.gembossUI = { openModal: openModal, toast: toast, confirm: confirm, initMenus: initMenus, closeMenus: closeMenus,
321
438
  initSearch: initSearch, initShell: initShell, initPasswordFields: initPasswordFields, resetPasswordFields: resetPasswordFields,
322
- openModals: function () { return MODALS.length; } };
439
+ initNav: initNav, openModals: function () { return MODALS.length; } };
323
440
  })(globalThis);
@@ -0,0 +1,6 @@
1
+ /** How long a pointer (or keyboard focus) must rest on a link before it is prefetched. Long enough that a sweep across
2
+ * the rail never stops this long on a link it is only passing; far shorter than the time from stopping to pressing the
3
+ * button. The admin's rail uses the same number (apps/admin/src/components/AppShell.tsx), and kit-plain.js (a classic
4
+ * script, cannot import) writes it as `NAV_DWELL = 120`: nav-pending.test.tsx holds the two equal.
5
+ * A plain module, not the "use client" one, so a server component that imports it gets a number. */
6
+ export const NAV_DWELL_MS = 120;
@@ -0,0 +1,4 @@
1
+ // @gemboss/ui/next (0.10.0): navigation feedback for Next.js App Router surfaces. Its own entry so the main entry never
2
+ // imports `next` (an optional peer): see ./nav.tsx.
3
+ export { NavLink, PendingIcon, PendingLink, type NavLinkProps } from "./nav";
4
+ export { NAV_DWELL_MS } from "../nav-dwell";
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Navigation feedback for a Next.js (App Router) surface: a click answers on the first frame, and the page a visitor
5
+ * is about to open is fetched while their pointer rests on it. The standard since 0.10.0 (Chris, 05/10/2026): "bấm vào
6
+ * menu cái icon chuyển thành icon loading, sub menu cũng có … rồi hover load trước".
7
+ *
8
+ * Source: content-radar's rail (gemlabs #828, #836), measured 04/10/2026 on a server ~280 ms away: a rail click left the
9
+ * screen unchanged for 321-503 ms; with this, 6-8 ms. Two things, each one a measured fix:
10
+ *
11
+ * 1. Prefetch on INTENT, not on sight. A rail has ~15 links all on screen at once, and Next's default prefetches every
12
+ * visible link, i.e. ~15 server requests per page view for pages nobody opens (the admin measured 25 full renders
13
+ * in 19 s with nobody touching the mouse, 23/08/2026). Here a link is prefetched when the pointer RESTS on it for
14
+ * NAV_DWELL_MS (keyboard focus too), or on touchstart; a pointer sweeping down the rail, or Tab run down it, fires nothing.
15
+ * 2. `useLinkStatus` (Next 15.3+): while the destination loads, the link carries a status child. Inside a rail item the
16
+ * shell CSS (app-shell.css) turns that into a spinner in place of the icon; PendingLink draws a small one by itself.
17
+ *
18
+ * Its own entry (`@gemboss/ui/next`) so the main entry never imports `next`: gemwatcher (Vite) and the plain-HTML
19
+ * surfaces load the package without it, and the admin's first load does not grow by a module it does not use.
20
+ */
21
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
22
+ import Link, { useLinkStatus } from "next/link";
23
+
24
+ import { NAV_DWELL_MS } from "../nav-dwell";
25
+
26
+ type LinkProps = React.ComponentProps<typeof Link>;
27
+
28
+ export type NavLinkProps = LinkProps & {
29
+ /** What to prefetch once the visitor shows intent. `null` (default): Next's own choice, which for a dynamic route is
30
+ * only its loading boundary (cheap, and it is what lets the click paint at once). `true`: the whole page. `false`:
31
+ * never. Before any intent it is always `false`. */
32
+ prefetch?: LinkProps["prefetch"];
33
+ /** The text a screen reader hears while the page loads. */
34
+ pendingLabel?: string;
35
+ };
36
+
37
+ type Mark = (label: string, href: LinkProps["href"]) => React.ReactNode;
38
+
39
+ /** next/link + prefetch on intent. `mark` renders inside the link (useLinkStatus is only readable there). */
40
+ function IntentLink({ prefetch = null, pendingLabel = "Loading", onMouseEnter, onMouseLeave, onFocus, onBlur, onTouchStart,
41
+ onMouseDown, children, mark, ...props }: NavLinkProps & { mark: Mark }) {
42
+ const [warm, setWarm] = useState(false);
43
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
44
+ const pressed = useRef(false);
45
+ useEffect(() => () => clearTimeout(timer.current), []);
46
+ const rest = () => { clearTimeout(timer.current); timer.current = setTimeout(() => setWarm(true), NAV_DWELL_MS); };
47
+ const stop = () => clearTimeout(timer.current);
48
+ return (
49
+ <Link {...props}
50
+ // kit-plain.js (loaded by most Next surfaces too, for confirm/toast) leaves a link marked this way to its owner.
51
+ data-gb-nav="next"
52
+ prefetch={warm ? prefetch : false}
53
+ onMouseEnter={(e) => { rest(); onMouseEnter?.(e); }}
54
+ onMouseLeave={(e) => { stop(); onMouseLeave?.(e); }}
55
+ // A press ends the dwell: a timer firing AFTER the click would change `prefetch` mid-navigation, and Next would
56
+ // remount this link's instance and lose track of it (the spinner then never clears on the next click).
57
+ onMouseDown={(e) => { stop(); pressed.current = true; onMouseDown?.(e); }}
58
+ // Focus rests like a pointer does: tabbing down the rail passes each link in well under 120 ms and fetches none. The
59
+ // focus a press gives the link is not keyboard intent, and arming on it would bring back the timer the press ended.
60
+ onFocus={(e) => { if (!pressed.current) rest(); onFocus?.(e); }}
61
+ onBlur={(e) => { stop(); pressed.current = false; onBlur?.(e); }}
62
+ // A touch is already deliberate (the tap's click follows in ~100 ms): no dwell to infer.
63
+ onTouchStart={(e) => { stop(); setWarm(true); onTouchStart?.(e); }}>
64
+ {children}
65
+ {mark(pendingLabel, props.href)}
66
+ </Link>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * `html[data-gb-navigating]` while any of these links is pending: what shows RouteCover (the holding frame over the
72
+ * content area) on the click's first frames. A layout effect, so the attribute lands before that frame is painted;
73
+ * counted, because a second click can start before the first navigation ends.
74
+ */
75
+ let navigating = 0;
76
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
77
+ /** The cover is for a NEW screen: a link to the page already shown (a tab that only changes `?tab=`) keeps its own small
78
+ * spinner and does not cover content that is not changing. */
79
+ function leavesThisPage(href: LinkProps["href"]): boolean {
80
+ const raw = typeof href === "string" ? href : (href.pathname ?? "");
81
+ try { return new URL(raw || ".", window.location.href).pathname !== window.location.pathname; } catch { return true; }
82
+ }
83
+ function useNavigatingFlag(pending: boolean, href: LinkProps["href"]) {
84
+ useBeforePaint(() => {
85
+ if (!pending || !leavesThisPage(href)) return;
86
+ navigating += 1;
87
+ document.documentElement.setAttribute("data-gb-navigating", "");
88
+ return () => {
89
+ navigating = Math.max(0, navigating - 1);
90
+ if (!navigating) document.documentElement.removeAttribute("data-gb-navigating");
91
+ };
92
+ }, [pending]);
93
+ }
94
+
95
+ /** The status a rail item carries while its page loads. The CSS reads it with `:has(> .gnav-pending)`. */
96
+ function RailStatus({ label, href }: { label: string; href: LinkProps["href"] }) {
97
+ const { pending } = useLinkStatus();
98
+ useNavigatingFlag(pending, href);
99
+ return pending ? <span className="gnav-pending" role="status">{label}</span> : null;
100
+ }
101
+
102
+ /** The kit's small spinner with its status text: what PendingLink and PendingIcon show while the page loads. */
103
+ function Spinner({ label }: { label: string }) {
104
+ return <span className="gb-pending-mark" role="status"><span className="ui-sr-only">{label}</span></span>;
105
+ }
106
+
107
+ function PageStatus({ label, href }: { label: string; href: LinkProps["href"] }) {
108
+ const { pending } = useLinkStatus();
109
+ useNavigatingFlag(pending, href);
110
+ return pending ? <Spinner label={label} /> : null;
111
+ }
112
+
113
+ const railMark: Mark = (label, href) => <RailStatus label={label} href={href} />;
114
+ const pageMark: Mark = (label, href) => <PageStatus label={label} href={href} />;
115
+
116
+ /**
117
+ * The link for the rail and for any menu: `<RailItem as={NavLink} href="/x" icon={…} label="X" />`, or
118
+ * `<RailSubItem as={NavLink} href="/x/y">Y</RailSubItem>`. Prefetches on intent; while the page loads, the rail item's
119
+ * icon becomes a spinner (a sub item gets a 12px one in its indent, before the label). Outside the rail it adds only the status text: use
120
+ * PendingLink there, or PendingIcon for a link with its own icon.
121
+ */
122
+ export function NavLink(props: NavLinkProps) {
123
+ return <IntentLink {...props} mark={railMark} />;
124
+ }
125
+
126
+ /**
127
+ * A link in the PAGE (a list row, a card, a tab): prefetch on intent, and a small spinner while its page loads.
128
+ * `mark="inline"` (default) puts it 12px after the text; `"corner"` puts it in the top right corner, out of the
129
+ * flow, for a link that wraps a whole card. Marked with `data-gb-pending`, not a class, so a surface that colours
130
+ * bare links with `a:not([class])` keeps its colour.
131
+ */
132
+ export function PendingLink({ mark = "inline", ...props }: NavLinkProps & { mark?: "inline" | "corner" }) {
133
+ return <IntentLink {...props} data-gb-pending={mark === "corner" ? "corner" : "inline"} mark={pageMark} />;
134
+ }
135
+
136
+ /** An icon that becomes the 20px spinner while ITS link's page loads. Must sit inside a next/link or a NavLink outside
137
+ * the rail (a tab with an icon). Not inside PendingLink, which draws its own spinner (two spinners, "Loading" read
138
+ * twice), and not in a rail item: `RailItem as={NavLink}` swaps its icon already. */
139
+ export function PendingIcon({ label = "Loading", children }: { label?: string; children?: React.ReactNode }) {
140
+ const { pending } = useLinkStatus();
141
+ return pending ? <Spinner label={label} /> : <>{children}</>;
142
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * RouteHolding (0.10.0): what a route group's `loading.jsx` renders while the next page is on its way.
3
+ *
4
+ * ONE frame for every route, not a skeleton per page: a title bar and four lines in the page's content column, no card
5
+ * edges. The admin's holding screen (apps/admin `.gemboss-route-shell`) is drawn the same way and for the same reason:
6
+ * pages run in several column widths, and a fake card at the wrong width is a layout jump of its own (the admin
7
+ * removed per-route skeletons for that).
8
+ *
9
+ * Why a surface on `@gemboss/ui/next` MUST have one: Next prefetches a dynamic route only down to its nearest loading
10
+ * boundary. Without a `loading.jsx` there is nothing to prefetch, so a click waits a whole server round trip with the
11
+ * old page on screen. Measured on content-radar 05/10/2026 (no loading file, server ~280 ms away): hover prefetches came
12
+ * back with 0.3-1.2 KB and the new screen took 350-780 ms, although the rail spinner showed at 5-38 ms. The admin
13
+ * measured the opposite case: deleting its `app/app/loading.tsx` cost 60-270 ms per click.
14
+ *
15
+ * Its own plain module, no hooks, no "use client": a server `loading.jsx` renders it, and nothing the admin imports
16
+ * pulls it into the admin's first load.
17
+ */
18
+ import React from "react";
19
+ import { pageWidthClass, type PageWidth } from "./page";
20
+
21
+ export function RouteHolding({ width = "default", label = "Loading", title }: {
22
+ /** The content column of the pages under this boundary (same tiers as PageContainer). Omit when the shell's
23
+ * PageContainer already caps the column. */
24
+ width?: PageWidth | "none";
25
+ /** What a screen reader hears. */
26
+ label?: string;
27
+ /** A heading to show instead of the title bar, when the boundary knows where it leads (one route, one name). */
28
+ title?: React.ReactNode;
29
+ }) {
30
+ const cls = width === "none" ? "gb-route-holding" : `gb-route-holding ${pageWidthClass(width)}`;
31
+ return (
32
+ <div className={cls} role={label ? "status" : undefined} aria-busy="true">
33
+ {label ? <span className="ui-sr-only">{label}</span> : null}
34
+ {title ? <div className="gb-route-holding__heading">{title}</div> : null}
35
+ <div className="ui-skeleton" aria-hidden="true">
36
+ {title ? null : <span className="ui-skeleton__line gb-route-holding__title" />}
37
+ <span className="ui-skeleton__line" /><span className="ui-skeleton__line" />
38
+ <span className="ui-skeleton__line" /><span className="ui-skeleton__line" />
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * RouteCover (0.10.0): the same frame, painted the moment a NavLink / PendingLink is clicked, over the content area
46
+ * (below the topbar, right of the rail), until the navigation commits. Render ONE in the layout, beside the page:
47
+ * `<AppShell …><PageContainer>{children}</PageContainer><RouteCover /></AppShell>`.
48
+ *
49
+ * Why, when the route already has a `loading.jsx`: Next can paint that boundary only once it has it, i.e. once a
50
+ * prefetch or the click's own request came back, one server round trip. Measured on content-radar 05/10/2026 through a
51
+ * 280 ms proxy: with the boundary the new screen came at ~300-350 ms, because a 120 ms hover rarely lets the prefetch
52
+ * finish. This frame is already in the page, so it paints in the click's first frames; the admin found the same and
53
+ * covers its content area from the shell (`.gemboss-route-shell`). It shows only while `html[data-gb-navigating]`,
54
+ * which the `@gemboss/ui/next` links set while pending; it fades in over 80 ms with no delay (the target is a new
55
+ * screen within 100 ms), so a navigation served from the router cache (20-90 ms) sees only a faint partial frame. Hidden from screen readers: the clicked link already announces "Loading".
56
+ * Geometry is the AppShell's (topbar 56px, rail 240px; full width at <=760px, where the rail is a drawer): a surface
57
+ * without that shell should render RouteHolding in its own content box instead.
58
+ */
59
+ export function RouteCover({ width = "default" }: { width?: PageWidth }) {
60
+ return (
61
+ <div className="gb-route-cover" aria-hidden="true">
62
+ <RouteHolding width={width} label="" />
63
+ </div>
64
+ );
65
+ }
package/src/shell.tsx CHANGED
@@ -97,12 +97,16 @@ export function RailGroup({ active, hasActiveChild, className, children }:
97
97
  * `.gnav-item`: icon + label (+ anything after, e.g. a count). Renders `<a>` by default; pass
98
98
  * `as={Link}` for a router link or `as="button"` for an action. `aria-current="page"` follows
99
99
  * `active` unless the caller sets it.
100
+ *
101
+ * `pending` (0.10.0): the page this item opens is on its way. It becomes `aria-busy="true"`, and the CSS
102
+ * (app-shell.css) swaps the icon for a spinner in the same 20px box and shows the wait cursor. A Next app
103
+ * does not set it by hand: `RailItem as={NavLink}` from `@gemboss/ui/next` reads the router's own status.
100
104
  */
101
- export function RailItem({ as: Comp = "a", icon, label, active, className, children, ...rest }:
102
- Polymorphic<{ icon?: React.ReactNode; label: React.ReactNode; active?: boolean; children?: React.ReactNode }>) {
105
+ export function RailItem({ as: Comp = "a", icon, label, active, pending, className, children, ...rest }:
106
+ Polymorphic<{ icon?: React.ReactNode; label: React.ReactNode; active?: boolean; pending?: boolean; children?: React.ReactNode }>) {
103
107
  return (
104
108
  <Comp className={cx("gnav-item", active && "active", className)}
105
- aria-current={active ? "page" : undefined} {...rest}>
109
+ aria-current={active ? "page" : undefined} aria-busy={pending || undefined} {...rest}>
106
110
  <span className="gnav-ico">{icon}</span>
107
111
  <span className="gnav-label">{label}</span>
108
112
  {children}
@@ -115,14 +119,15 @@ export function RailSub({ children }: { children?: React.ReactNode }) {
115
119
  return <ul className="gnav-sub">{children}</ul>;
116
120
  }
117
121
 
118
- /** One `li` of `RailSub`. `soon` renders a disabled entry with a "Soon" tag instead of a link. */
119
- export function RailSubItem({ as: Comp = "a", active, soon, soonLabel = "Soon", className, children, ...rest }:
120
- Polymorphic<{ active?: boolean; soon?: boolean; soonLabel?: string; children?: React.ReactNode }>) {
122
+ /** One `li` of `RailSub`. `soon` renders a disabled entry with a "Soon" tag instead of a link. `pending`: as on
123
+ * `RailItem`; a sub item has no icon, so the spinner (12px) sits in its indent, before the label, out of the flow. */
124
+ export function RailSubItem({ as: Comp = "a", active, pending, soon, soonLabel = "Soon", className, children, ...rest }:
125
+ Polymorphic<{ active?: boolean; pending?: boolean; soon?: boolean; soonLabel?: string; children?: React.ReactNode }>) {
121
126
  if (soon) return <li><span className="gnav-subitem is-soon">{children}<em>{soonLabel}</em></span></li>;
122
127
  return (
123
128
  <li>
124
129
  <Comp className={cx("gnav-subitem", active && "active", className)}
125
- aria-current={active ? "page" : undefined} {...rest}>{children}</Comp>
130
+ aria-current={active ? "page" : undefined} aria-busy={pending || undefined} {...rest}>{children}</Comp>
126
131
  </li>
127
132
  );
128
133
  }