@ubx/docs-ui 0.10.0 → 0.11.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/dist/Header.d.ts CHANGED
@@ -8,13 +8,19 @@ export type SectionTab = {
8
8
  label: string;
9
9
  href: string;
10
10
  };
11
- export declare function Header({ nav, tabs, activeTab, mobileMenu, search, showThemeToggle, githubUrl, }: {
11
+ export declare function Header({ nav, tabs, activeTab, sidebar, sidebarLabel, search, showThemeToggle, githubUrl, }: {
12
12
  nav: NavLink[];
13
13
  /** Omit entirely for a single-tier header, which is the provider site. */
14
14
  tabs?: SectionTab[];
15
15
  /** href of the active tab, matched by prefix so nested pages stay lit. */
16
16
  activeTab?: string;
17
- mobileMenu?: React.ReactNode;
17
+ /**
18
+ * The section tree, when the page has one. Goes into the mobile
19
+ * drawer; the desktop rail is PageShell's, not the header's.
20
+ */
21
+ sidebar?: React.ReactNode;
22
+ /** Heading for the tree inside the drawer, in the site's own words. */
23
+ sidebarLabel?: string;
18
24
  /**
19
25
  * Search control, rendered to the left of the theme toggle.
20
26
  *
package/dist/Header.js CHANGED
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
3
  import { ThemeToggle } from "./ThemeToggle";
4
4
  import { MobileNav } from "./MobileNav";
5
- export function Header({ nav, tabs, activeTab, mobileMenu, search, showThemeToggle = true, githubUrl, }) {
6
- return (_jsxs("header", { className: "border-b border-border bg-background", children: [_jsxs("div", { className: "relative mx-auto flex max-w-7xl items-center gap-3 px-6 py-4", children: [mobileMenu, _jsxs(Link, { href: "/", className: "flex shrink-0 items-center", children: [_jsx("img", { src: "/logo/logo.png", alt: "ubx", className: "logo-light h-6 w-auto" }), _jsx("img", { src: "/logo/logo-dark.png", alt: "ubx", className: "logo-dark h-6 w-auto" })] }), _jsx("div", { className: "flex-1" }), _jsx("nav", { className: "absolute left-1/2 hidden -translate-x-1/2 items-center gap-5 md:flex", children: nav.map((item) => {
5
+ export function Header({ nav, tabs, activeTab, sidebar, sidebarLabel, search, showThemeToggle = true, githubUrl, }) {
6
+ return (_jsxs("header", { className: "border-b border-border bg-background", children: [_jsxs("div", { className: "relative mx-auto flex max-w-7xl items-center gap-3 px-6 py-4", children: [_jsx(Link, { href: "/", className: "flex shrink-0 items-center", children: _jsx("img", { src: "/logo/ubiquex.png", alt: "Ubiquex", className: "h-6 w-auto" }) }), _jsx("div", { className: "flex-1" }), _jsx("nav", { className: "absolute left-1/2 hidden -translate-x-1/2 items-center gap-5 md:flex", children: nav.map((item) => {
7
7
  const className = item.current
8
8
  ? "text-sm text-primary"
9
9
  : "text-sm text-foreground-muted hover:text-primary";
10
10
  return item.href.startsWith("/") ? (_jsx(Link, { href: item.href, className: className, children: item.label }, item.label)) : (_jsx("a", { href: item.href, className: className, children: item.label }, item.label));
11
- }) }), search ? _jsx("div", { className: "w-40 shrink-0 sm:w-56 lg:w-64", children: search }) : null, githubUrl ? (_jsxs("a", { href: githubUrl, className: "hidden shrink-0 items-center gap-2 rounded-full bg-primary px-4 py-[7px] text-sm text-primary-foreground transition-opacity hover:opacity-90 sm:flex", children: [_jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" }) }), "Star us on GitHub"] })) : null, showThemeToggle ? _jsx(ThemeToggle, {}) : null, _jsx(MobileNav, { nav: nav, githubUrl: githubUrl })] }), tabs && tabs.length > 0 ? (_jsx("div", { className: "mx-auto max-w-7xl px-6", children: _jsx("nav", { className: "flex gap-6 overflow-x-auto", children: tabs.map((tab) => {
11
+ }) }), search ? _jsx("div", { className: "w-40 shrink-0 sm:w-56 lg:w-64", children: search }) : null, githubUrl ? (_jsxs("a", { href: githubUrl, className: "hidden shrink-0 items-center gap-2 rounded-full bg-primary px-4 py-[7px] text-sm text-primary-foreground transition-opacity hover:opacity-90 sm:flex", children: [_jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" }) }), "Star us on GitHub"] })) : null, showThemeToggle ? (_jsx("div", { className: "hidden md:block", children: _jsx(ThemeToggle, {}) })) : null, _jsx(MobileNav, { nav: nav, githubUrl: githubUrl, sidebar: sidebar, sidebarLabel: sidebarLabel, showThemeToggle: showThemeToggle })] }), tabs && tabs.length > 0 ? (_jsx("div", { className: "mx-auto max-w-7xl px-6", children: _jsx("nav", { className: "flex gap-6 overflow-x-auto", children: tabs.map((tab) => {
12
12
  // Prefix match, so /concepts/ledger keeps the Concepts tab
13
13
  // lit rather than only the exact section index.
14
14
  const active = activeTab === tab.href || activeTab?.startsWith(tab.href + "/");
@@ -1,5 +1,9 @@
1
1
  import type { NavLink } from "./Header";
2
- export declare function MobileNav({ nav, githubUrl }: {
2
+ export declare function MobileNav({ nav, githubUrl, sidebar, sidebarLabel, showThemeToggle, }: {
3
3
  nav: NavLink[];
4
4
  githubUrl?: string;
5
+ /** The section tree, when the page has one. Rendered below the destinations. */
6
+ sidebar?: React.ReactNode;
7
+ sidebarLabel?: string;
8
+ showThemeToggle?: boolean;
5
9
  }): import("react").JSX.Element;
package/dist/MobileNav.js CHANGED
@@ -1,19 +1,37 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import Link from "next/link";
4
- import { useEffect, useState } from "react";
5
- // The header's destination nav is `hidden md:flex`, so below 768px it was
6
- // simply gone. Measured on all three sites: navDisplay "none", and the
7
- // only visible header link was the logo. There was no route to Install,
8
- // Documentation, Tutorials, Providers or Blog on a phone, anywhere.
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { ThemeToggle } from "./ThemeToggle";
6
+ // The one mobile navigation control on all three sites.
9
7
  //
10
- // Same two-state open/close as MobileSidebarToggle, and for the same
11
- // reason: closing flips `visible` immediately so the transition can play,
12
- // and the drawer only leaves the DOM once it has had time to finish.
8
+ // It used to be two. The header's destination nav is `hidden md:flex`,
9
+ // so below 768px it was gone, and this drawer was added to reach it.
10
+ // But the docs sites ALSO rendered MobileSidebarToggle, a second burger
11
+ // on the left opening a second drawer, for the section tree. A reader on
12
+ // a phone met two hamburger buttons that looked identical, opened from
13
+ // opposite edges, and each held half the navigation.
14
+ //
15
+ // So this component now holds both: destinations first, then the section
16
+ // tree when the page has one. MobileSidebarToggle is gone rather than
17
+ // kept alongside, because two implementations of one drawer is how the
18
+ // two drifted in the first place.
19
+ //
20
+ // Opens DOWNWARD from below the header rather than in from an edge. A
21
+ // side sheet reads as a separate surface that arrived over the page; a
22
+ // panel dropping from the bar reads as the bar itself expanding, which
23
+ // is what it is.
13
24
  const TRANSITION_MS = 200;
14
- export function MobileNav({ nav, githubUrl }) {
25
+ export function MobileNav({ nav, githubUrl, sidebar, sidebarLabel, showThemeToggle = true, }) {
15
26
  const [mounted, setMounted] = useState(false);
16
27
  const [visible, setVisible] = useState(false);
28
+ // Where the header actually ends, measured rather than assumed. The
29
+ // header is one tier on the marketing site and two on the docs sites
30
+ // (destinations plus a section tab strip), and a hardcoded offset
31
+ // would drop the panel over the tab strip on exactly the sites that
32
+ // have one.
33
+ const [top, setTop] = useState(0);
34
+ const buttonRef = useRef(null);
17
35
  useEffect(() => {
18
36
  if (!mounted)
19
37
  return;
@@ -23,10 +41,23 @@ export function MobileNav({ nav, githubUrl }) {
23
41
  if (e.key === "Escape")
24
42
  close();
25
43
  };
44
+ // Re-measure on resize: rotating a phone changes the header's
45
+ // height whenever anything in it wraps.
46
+ const onResize = () => {
47
+ const header = buttonRef.current?.closest("header");
48
+ if (header)
49
+ setTop(header.getBoundingClientRect().bottom);
50
+ };
26
51
  document.addEventListener("keydown", onKey);
27
- return () => document.removeEventListener("keydown", onKey);
52
+ window.addEventListener("resize", onResize);
53
+ return () => {
54
+ document.removeEventListener("keydown", onKey);
55
+ window.removeEventListener("resize", onResize);
56
+ };
28
57
  }, [mounted]);
29
58
  function open() {
59
+ const header = buttonRef.current?.closest("header");
60
+ setTop(header ? header.getBoundingClientRect().bottom : 0);
30
61
  setMounted(true);
31
62
  requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
32
63
  }
@@ -34,8 +65,9 @@ export function MobileNav({ nav, githubUrl }) {
34
65
  setVisible(false);
35
66
  setTimeout(() => setMounted(false), TRANSITION_MS);
36
67
  }
37
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: open, "aria-label": "Open navigation", "aria-expanded": mounted, className: "flex h-9 w-9 shrink-0 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary md:hidden", children: _jsx("svg", { viewBox: "0 0 20 20", width: "20", height: "20", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) }), mounted && (_jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": "Navigation", className: "fixed inset-0 z-50 md:hidden", children: [_jsx("button", { type: "button", "aria-label": "Close navigation", onClick: close, className: "absolute inset-0 bg-foreground/40 transition-opacity duration-200 motion-reduce:transition-none " +
38
- (visible ? "opacity-100" : "opacity-0") }), _jsxs("div", { className: "absolute inset-y-0 right-0 flex w-72 max-w-[85vw] flex-col gap-1 overflow-y-auto bg-background p-5 shadow-lg " +
39
- "transition-transform duration-200 ease-out motion-reduce:transition-none " +
40
- (visible ? "translate-x-0" : "translate-x-full"), children: [_jsx("button", { type: "button", onClick: close, "aria-label": "Close navigation", className: "mb-3 flex h-8 w-8 items-center justify-center self-end rounded text-foreground-muted hover:bg-surface hover:text-primary", children: _jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) }), nav.map((item) => item.href.startsWith("/") ? (_jsx(Link, { href: item.href, onClick: close, className: item.current ? "py-2 text-primary" : "py-2 text-foreground-muted hover:text-primary", children: item.label }, item.label)) : (_jsx("a", { href: item.href, onClick: close, className: item.current ? "py-2 text-primary" : "py-2 text-foreground-muted hover:text-primary", children: item.label }, item.label))), githubUrl ? (_jsx("a", { href: githubUrl, className: "mt-3 rounded-full bg-primary px-4 py-2 text-center text-sm text-primary-foreground", children: "Star us on GitHub" })) : null] })] }))] }));
68
+ const linkClass = (current) => current ? "py-2 text-primary" : "py-2 text-foreground-muted hover:text-primary";
69
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: buttonRef, type: "button", onClick: mounted ? close : open, "aria-label": mounted ? "Close navigation" : "Open navigation", "aria-expanded": mounted, className: "flex h-9 w-9 shrink-0 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary md:hidden", children: mounted ? (_jsx("svg", { viewBox: "0 0 20 20", width: "20", height: "20", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) })) : (_jsx("svg", { viewBox: "0 0 20 20", width: "20", height: "20", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) })) }), mounted && (_jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": "Navigation", className: "fixed inset-x-0 bottom-0 z-50 md:hidden", style: { top }, children: [_jsx("button", { type: "button", "aria-label": "Close navigation", onClick: close, className: "absolute inset-0 bg-foreground/40 transition-opacity duration-200 motion-reduce:transition-none " +
70
+ (visible ? "opacity-100" : "opacity-0") }), _jsx("div", { className: "absolute inset-x-0 top-0 max-h-full overflow-hidden", children: _jsxs("div", { className: "flex max-h-[calc(100vh-8rem)] flex-col gap-1 overflow-y-auto border-b border-border bg-background px-6 pt-4 pb-6 shadow-lg " +
71
+ "transition-transform duration-200 ease-out motion-reduce:transition-none " +
72
+ (visible ? "translate-y-0" : "-translate-y-full"), children: [nav.map((item) => item.href.startsWith("/") ? (_jsx(Link, { href: item.href, onClick: close, className: linkClass(item.current), children: item.label }, item.label)) : (_jsx("a", { href: item.href, onClick: close, className: linkClass(item.current), children: item.label }, item.label))), sidebar ? (_jsxs("div", { className: "mt-4 border-t border-border pt-4", children: [sidebarLabel ? (_jsx("div", { className: "mb-2 text-xs tracking-wide text-foreground-muted uppercase", children: sidebarLabel })) : null, _jsx("div", { onClick: close, children: sidebar })] })) : null, githubUrl ? (_jsx("a", { href: githubUrl, className: "mt-4 rounded-full bg-primary px-4 py-2 text-center text-sm text-primary-foreground", children: "Star us on GitHub" })) : null, showThemeToggle ? (_jsxs("div", { className: "mt-4 flex items-center justify-between border-t border-border pt-4", children: [_jsx("span", { className: "text-sm text-foreground-muted", children: "Theme" }), _jsx(ThemeToggle, {})] })) : null] }) })] }))] }));
41
73
  }
@@ -78,16 +78,23 @@ export type PageShellProps = {
78
78
  };
79
79
  export declare function PageShell({ nav, tabs, activeTab, sidebar, sidebarLabel, searchPlaceholder, searchPlacement, intro, showThemeToggle, githubUrl, fullBleed, footer, children, }: PageShellProps): React.JSX.Element;
80
80
  /**
81
- * Sets data-theme from localStorage before paint.
81
+ * Sets data-theme before paint.
82
82
  *
83
83
  * Both sites carried this as a verbatim string constant in their own root
84
84
  * layout, which is duplication the package could not see. It has to be an
85
85
  * exported string rather than a component because it goes in <head> via
86
86
  * dangerouslySetInnerHTML and must run ahead of hydration: a React
87
87
  * component would run too late and the reader would see a flash of the
88
- * OS default before their stored choice applied.
88
+ * default before their stored choice applied.
89
89
  *
90
- * Absent or invalid storage leaves no attribute at all, which is exactly
91
- * "follow the OS".
90
+ * DARK IS THE DEFAULT, and absent storage now means dark rather than
91
+ * "follow the OS". The three sites are one product, and which one a
92
+ * reader saw first decided whether it was a dark product or a light one.
93
+ *
94
+ * "system" is still a real, selectable state; it is now an explicit
95
+ * stored choice rather than the absence of one. That distinction is why
96
+ * the key is written on every choice below rather than removed for
97
+ * system: with dark as the default, removing the key would mean dark,
98
+ * not system, and the option would silently do nothing.
92
99
  */
93
- export declare const THEME_INIT_SCRIPT = "(function () {\n try {\n var stored = window.localStorage.getItem(\"ubx-docs-theme\");\n if (stored === \"light\" || stored === \"dark\") {\n document.documentElement.setAttribute(\"data-theme\", stored);\n }\n } catch (e) {}\n})();";
100
+ export declare const THEME_INIT_SCRIPT = "(function () {\n var stored = null;\n try {\n stored = window.localStorage.getItem(\"ubx-docs-theme\");\n } catch (e) {}\n if (stored === \"light\" || stored === \"dark\") {\n document.documentElement.setAttribute(\"data-theme\", stored);\n } else if (stored !== \"system\") {\n document.documentElement.setAttribute(\"data-theme\", \"dark\");\n }\n})();";
package/dist/PageShell.js CHANGED
@@ -2,7 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Header } from "./Header";
3
3
  import { Footer } from "./Footer";
4
4
  import { GlobalSearch } from "./GlobalSearch";
5
- import { MobileSidebarToggle } from "./MobileSidebarToggle";
6
5
  /** Compact, for sitting beside the theme toggle. */
7
6
  const HEADER_SEARCH_CLASS = "w-full rounded-full bg-field px-4 py-1.5 text-sm text-foreground outline-none " +
8
7
  "placeholder:text-foreground-muted focus:ring-2 focus:ring-primary/30";
@@ -12,29 +11,39 @@ const HERO_SEARCH_CLASS = "w-full rounded-full bg-field px-5 py-3 text-base text
12
11
  export function PageShell({ nav, tabs, activeTab, sidebar, sidebarLabel = "Navigation", searchPlaceholder, searchPlacement = "header", intro, showThemeToggle = true, githubUrl, fullBleed = false, footer, children, }) {
13
12
  const headerSearch = searchPlaceholder && searchPlacement === "header" ? (_jsx(GlobalSearch, { placeholder: searchPlaceholder, inputClassName: HEADER_SEARCH_CLASS })) : undefined;
14
13
  const heroSearch = searchPlaceholder && searchPlacement === "hero" ? (_jsx("div", { className: "mx-auto mt-8 max-w-xl", children: _jsx(GlobalSearch, { placeholder: searchPlaceholder, inputClassName: HERO_SEARCH_CLASS }) })) : null;
15
- return (_jsxs(_Fragment, { children: [_jsx(Header, { nav: nav, tabs: tabs, activeTab: activeTab, search: headerSearch, showThemeToggle: showThemeToggle, githubUrl: githubUrl, mobileMenu: sidebar ? (_jsx(MobileSidebarToggle, { label: sidebarLabel, children: sidebar })) : undefined }), sidebar ? (
14
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { nav: nav, tabs: tabs, activeTab: activeTab, search: headerSearch, showThemeToggle: showThemeToggle, githubUrl: githubUrl, sidebar: sidebar, sidebarLabel: sidebarLabel }), sidebar ? (
16
15
  // No intro or hero search here on purpose: a page with a sidebar
17
16
  // is a content page, and a hero belongs to a landing page.
18
17
  _jsxs("div", { className: "mx-auto flex w-full max-w-7xl flex-1 gap-10 px-6 py-10", children: [_jsx("aside", { className: "hidden w-64 shrink-0 lg:block", children: sidebar }), _jsx("main", { className: "min-w-0 flex-1", children: children })] })) : fullBleed ? (_jsxs("main", { className: "flex-1", children: [intro, heroSearch, children] })) : (_jsxs("main", { className: "mx-auto w-full max-w-7xl flex-1 px-6 py-12", children: [intro, heroSearch, children] })), _jsx(Footer, { tagline: footer.tagline, links: footer.links })] }));
19
18
  }
20
19
  /**
21
- * Sets data-theme from localStorage before paint.
20
+ * Sets data-theme before paint.
22
21
  *
23
22
  * Both sites carried this as a verbatim string constant in their own root
24
23
  * layout, which is duplication the package could not see. It has to be an
25
24
  * exported string rather than a component because it goes in <head> via
26
25
  * dangerouslySetInnerHTML and must run ahead of hydration: a React
27
26
  * component would run too late and the reader would see a flash of the
28
- * OS default before their stored choice applied.
27
+ * default before their stored choice applied.
29
28
  *
30
- * Absent or invalid storage leaves no attribute at all, which is exactly
31
- * "follow the OS".
29
+ * DARK IS THE DEFAULT, and absent storage now means dark rather than
30
+ * "follow the OS". The three sites are one product, and which one a
31
+ * reader saw first decided whether it was a dark product or a light one.
32
+ *
33
+ * "system" is still a real, selectable state; it is now an explicit
34
+ * stored choice rather than the absence of one. That distinction is why
35
+ * the key is written on every choice below rather than removed for
36
+ * system: with dark as the default, removing the key would mean dark,
37
+ * not system, and the option would silently do nothing.
32
38
  */
33
39
  export const THEME_INIT_SCRIPT = `(function () {
40
+ var stored = null;
34
41
  try {
35
- var stored = window.localStorage.getItem("ubx-docs-theme");
36
- if (stored === "light" || stored === "dark") {
37
- document.documentElement.setAttribute("data-theme", stored);
38
- }
42
+ stored = window.localStorage.getItem("ubx-docs-theme");
39
43
  } catch (e) {}
44
+ if (stored === "light" || stored === "dark") {
45
+ document.documentElement.setAttribute("data-theme", stored);
46
+ } else if (stored !== "system") {
47
+ document.documentElement.setAttribute("data-theme", "dark");
48
+ }
40
49
  })();`;
@@ -29,6 +29,7 @@ function subscribe(callback) {
29
29
  window.removeEventListener("ubx-theme-change", callback);
30
30
  };
31
31
  }
32
+ const DEFAULT = "dark";
32
33
  function getSnapshot() {
33
34
  // Same real risk app/layout.tsx's own inline THEME_INIT_SCRIPT
34
35
  // already guards against for the identical read -- private
@@ -41,14 +42,18 @@ function getSnapshot() {
41
42
  // control.
42
43
  try {
43
44
  const stored = window.localStorage.getItem(STORAGE_KEY);
44
- return stored === "light" || stored === "dark" ? stored : "system";
45
+ return stored === "light" || stored === "dark" || stored === "system" ? stored : DEFAULT;
45
46
  }
46
47
  catch {
47
- return "system";
48
+ return DEFAULT;
48
49
  }
49
50
  }
50
51
  function getServerSnapshot() {
51
- return "system";
52
+ // The server has no localStorage, and this is also the correct first
53
+ // client answer: THEME_INIT_SCRIPT has already put the real
54
+ // data-theme on the document by the time this renders, so only this
55
+ // control's own highlight can lag, never the page.
56
+ return DEFAULT;
52
57
  }
53
58
  function SystemIcon() {
54
59
  return (_jsxs("svg", { viewBox: "0 0 16 16", width: "14", height: "14", fill: "none", "aria-hidden": "true", children: [_jsx("rect", { x: "1.5", y: "2.5", width: "13", height: "8.5", rx: "1", stroke: "currentColor", strokeWidth: "1.3" }), _jsx("path", { d: "M5.5 14h5M8 11v3", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" })] }));
@@ -68,15 +73,16 @@ const OPTIONS = [
68
73
  // always visible -- replaces the earlier single cycling button (whose
69
74
  // current state was legible only from its text label, distinct icons
70
75
  // were the whole point of this pass).
76
+ //
77
+ // Rendered in the desktop header and, below md, inside the mobile
78
+ // drawer instead. Three icons plus a logo, a burger and a call to
79
+ // action did not fit a phone-width bar.
71
80
  export function ThemeToggle() {
72
81
  const choice = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
73
82
  function choose(next) {
74
83
  applyTheme(next);
75
84
  try {
76
- if (next === "system")
77
- window.localStorage.removeItem(STORAGE_KEY);
78
- else
79
- window.localStorage.setItem(STORAGE_KEY, next);
85
+ window.localStorage.setItem(STORAGE_KEY, next);
80
86
  }
81
87
  catch {
82
88
  // Storage blocked -- the choice still applies to this page via
package/dist/index.d.ts CHANGED
@@ -7,7 +7,6 @@ export { GlobalSearch } from "./GlobalSearch";
7
7
  export type { SearchEntry } from "./GlobalSearch";
8
8
  export { ThemeToggle } from "./ThemeToggle";
9
9
  export { Footer } from "./Footer";
10
- export { MobileSidebarToggle } from "./MobileSidebarToggle";
11
10
  export { PageShell, THEME_INIT_SCRIPT } from "./PageShell";
12
11
  export type { PageShellProps } from "./PageShell";
13
12
  export { MobileNav } from "./MobileNav";
package/dist/index.js CHANGED
@@ -3,7 +3,6 @@
3
3
  //
4
4
  // Extracted only after both consumers existed, deliberately. Three of
5
5
  // these interfaces had to change to be shareable at all, and one of
6
- // those was NOT predicted: MobileSidebarToggle imported ProviderSidebar
7
6
  // directly, which was found when the second site failed to build.
8
7
  // Extracting against a single call site would have carried that coupling
9
8
  // into this package unnoticed.
@@ -13,6 +12,5 @@ export { Header } from "./Header";
13
12
  export { GlobalSearch } from "./GlobalSearch";
14
13
  export { ThemeToggle } from "./ThemeToggle";
15
14
  export { Footer } from "./Footer";
16
- export { MobileSidebarToggle } from "./MobileSidebarToggle";
17
15
  export { PageShell, THEME_INIT_SCRIPT } from "./PageShell";
18
16
  export { MobileNav } from "./MobileNav";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubx/docs-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Shared UI for ubx documentation sites: the page shell, theme A, Shiki-backed CodeBlock, header, search, theme toggle.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,8 +0,0 @@
1
- export declare function MobileSidebarToggle({ children, label, }: {
2
- children: React.ReactNode;
3
- /**
4
- * What this drawer contains, in the consuming site's own vocabulary.
5
- * Used as the drawer heading and as the basis for both aria-labels.
6
- */
7
- label: string;
8
- }): import("react").JSX.Element;
@@ -1,90 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { usePathname } from "next/navigation";
4
- import { useEffect, useRef, useState } from "react";
5
- // Opens from the header, since the desktop sidebar (ProviderSidebar's
6
- // own "hidden lg:block" rail) contributes nothing below that
7
- // breakpoint -- without this, a reader on mobile has no way to reach
8
- // a provider's service groups or resources at all once past the
9
- // provider home page. Renders the identical ProviderSidebar (same
10
- // fetch, same filter, same tree) inside a drawer rather than a
11
- // second, parallel mobile nav that could drift from the real one.
12
- //
13
- // Two-state open/close (mounted + visible, not a single boolean) so
14
- // the close transition has time to actually play: closing flips
15
- // `visible` off immediately (the CSS transition animates toward the
16
- // closed position) and only removes the drawer from the DOM once that
17
- // transition has had time to finish, rather than the element
18
- // vanishing the instant the tap registers.
19
- const TRANSITION_MS = 200;
20
- // UBI-247: takes the drawer contents as `children` rather than
21
- // constructing a ProviderSidebar itself. The provider site passes
22
- // <ProviderSidebar .../>, this site passes <DocSidebar .../>, and the
23
- // open/close/transition/route-change behaviour below is identical for
24
- // both.
25
- //
26
- // This comment used to claim that the `children` coupling "was the one
27
- // thing in this component that was not generic." That was wrong, and it
28
- // is left recorded here rather than quietly deleted because the way it
29
- // was wrong is the useful part. Three strings a few lines below still
30
- // said "Services" and "service navigation", which is AWS provider
31
- // vocabulary: the provider site's mobile drawer holds a provider's
32
- // service groups. The user docs site's drawer holds documentation
33
- // pages, so it announced the wrong thing to every mobile reader and to
34
- // every screen reader.
35
- //
36
- // It survived because the extraction was audited per component by
37
- // reading each one, and reading is exactly what does not catch a
38
- // plausible-sounding noun. It was found later by mechanically
39
- // extracting every user-visible string literal in the package and
40
- // asking of each one whether it could be true on both sites. `label` is
41
- // required for the same reason Footer's props are: a default here is a
42
- // silent claim about which site you are.
43
- export function MobileSidebarToggle({ children, label, }) {
44
- const [mounted, setMounted] = useState(false);
45
- const [visible, setVisible] = useState(false);
46
- const closeTimer = useRef(null);
47
- const pathname = usePathname();
48
- const isFirstPathname = useRef(true);
49
- function clearCloseTimer() {
50
- if (closeTimer.current) {
51
- clearTimeout(closeTimer.current);
52
- closeTimer.current = null;
53
- }
54
- }
55
- function open() {
56
- clearCloseTimer();
57
- setMounted(true);
58
- // Mounts in the closed visual position first -- flipping to
59
- // `visible` one frame later gives the browser an actual "from"
60
- // state to transition out of, rather than painting already open.
61
- requestAnimationFrame(() => {
62
- requestAnimationFrame(() => setVisible(true));
63
- });
64
- }
65
- function close() {
66
- setVisible(false);
67
- clearCloseTimer();
68
- closeTimer.current = setTimeout(() => setMounted(false), TRANSITION_MS);
69
- }
70
- useEffect(() => clearCloseTimer, []);
71
- // Explicit close-on-navigation rather than relying on this
72
- // component happening to remount between routes -- Header sits
73
- // directly in each page.tsx, not behind a shared layout boundary
74
- // between different resource pages, so whether React actually tears
75
- // this instance down on every navigation isn't guaranteed. Skips
76
- // the very first render (mount already starts closed) so this only
77
- // ever fires on a real route change.
78
- useEffect(() => {
79
- if (isFirstPathname.current) {
80
- isFirstPathname.current = false;
81
- return;
82
- }
83
- close();
84
- // eslint-disable-next-line react-hooks/exhaustive-deps
85
- }, [pathname]);
86
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: open, "aria-label": `Open ${label} navigation`, className: "flex h-9 w-9 shrink-0 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary lg:hidden", children: _jsx("svg", { viewBox: "0 0 20 20", width: "20", height: "20", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) }), mounted && (_jsxs("div", { role: "dialog", "aria-modal": "true", className: "fixed inset-0 z-50 lg:hidden", children: [_jsx("button", { type: "button", "aria-label": `Close ${label} navigation`, onClick: close, className: "absolute inset-0 bg-foreground/40 transition-opacity duration-200 motion-reduce:transition-none " +
87
- (visible ? "opacity-100" : "opacity-0") }), _jsxs("div", { className: "absolute inset-y-0 left-0 flex w-80 max-w-[85vw] flex-col overflow-y-auto bg-background p-4 shadow-lg " +
88
- "transition-transform duration-200 ease-out motion-reduce:transition-none " +
89
- (visible ? "translate-x-0" : "-translate-x-full"), children: [_jsxs("div", { className: "mb-3 flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: label }), _jsx("button", { type: "button", onClick: close, "aria-label": `Close ${label} navigation`, className: "flex h-8 w-8 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary", children: _jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) })] }), children] })] }))] }));
90
- }