@ubx/docs-ui 0.2.0 → 0.4.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/Footer.d.ts CHANGED
@@ -1 +1,9 @@
1
- export declare function Footer(): import("react").JSX.Element;
1
+ export declare function Footer({ tagline, links, }: {
2
+ /** The one-line statement of what this particular site's content is. */
3
+ tagline: React.ReactNode;
4
+ /** Footer destinations, stated per site rather than shared. */
5
+ links: {
6
+ label: string;
7
+ href: string;
8
+ }[];
9
+ }): import("react").JSX.Element;
package/dist/Footer.js CHANGED
@@ -2,10 +2,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Footer: Material style, a hairline divider and quiet text, not a
3
3
  // heavy block -- the same restrained treatment already established
4
4
  // for hairline dividers site-wide (globals.css's own --color-border).
5
- // Links repeat two of Header's own real, checked destinations
6
- // (Documentation, GitHub) rather than inventing new ones, plus a
7
- // License link to this repo's own real LICENSE file (Apache 2.0,
8
- // confirmed directly from the file, not assumed).
9
- export function Footer() {
10
- return (_jsx("footer", { className: "border-t border-border", children: _jsxs("div", { className: "mx-auto max-w-7xl px-6 py-8 text-sm text-foreground-muted", children: [_jsxs("div", { className: "flex flex-col items-center gap-3 sm:flex-row sm:justify-between", children: [_jsx("p", { children: "Reference content is generated from each provider\u2019s own real schema, not hand-written." }), _jsxs("nav", { className: "flex items-center gap-5", children: [_jsx("a", { href: "https://docs.ubiquex.io", className: "hover:text-primary", children: "Documentation" }), _jsx("a", { href: "https://github.com/Ubiquex", className: "hover:text-primary", children: "GitHub" }), _jsx("a", { href: "https://github.com/Ubiquex/ubx-docs-providers/blob/main/LICENSE", className: "hover:text-primary", children: "License" })] })] }), _jsx("p", { className: "mt-4 text-center text-xs sm:text-left", children: "\u00A9 2026 Ubiquex" })] }) }));
5
+ //
6
+ // IDENTITY IS A PROP, NOT A DEFAULT, for the same reason it is on
7
+ // GlobalSearch, and this component is the proof that the reason is real.
8
+ //
9
+ // Extracted with the provider site's own identity hardcoded: a tagline
10
+ // reading "Reference content is generated from each provider's own real
11
+ // schema, not hand-written" and a License link pointing at
12
+ // ubx-docs-providers. Both were correct for the site they came from and
13
+ // silently wrong on the user docs site, where all 137 pages are
14
+ // hand-written MDX and the repo is a different one. The tagline did not
15
+ // merely fail to apply, it asserted the exact opposite of the truth on
16
+ // every page of the site.
17
+ //
18
+ // GlobalSearch was caught during extraction because its regression was
19
+ // visible (a pill turned into a rectangle). This one was not, because
20
+ // wrong prose looks exactly like right prose until someone reads it. So
21
+ // the rule cannot be "check carefully during extraction"; it has to be
22
+ // that the type system refuses to let a site inherit an identity it did
23
+ // not state. Hence both props are required and neither has a default.
24
+ export function Footer({ tagline, links, }) {
25
+ return (_jsx("footer", { className: "border-t border-border", children: _jsxs("div", { className: "mx-auto max-w-7xl px-6 py-8 text-sm text-foreground-muted", children: [_jsxs("div", { className: "flex flex-col items-center gap-3 sm:flex-row sm:justify-between", children: [_jsx("p", { children: tagline }), _jsx("nav", { className: "flex items-center gap-5", children: links.map((l) => (_jsx("a", { href: l.href, className: "hover:text-primary", children: l.label }, l.href))) })] }), _jsx("p", { className: "mt-4 text-center text-xs sm:text-left", children: "\u00A9 2026 Ubiquex" })] }) }));
11
26
  }
package/dist/Header.d.ts CHANGED
@@ -8,11 +8,21 @@ export type SectionTab = {
8
8
  label: string;
9
9
  href: string;
10
10
  };
11
- export declare function Header({ nav, tabs, activeTab, mobileMenu, }: {
11
+ export declare function Header({ nav, tabs, activeTab, mobileMenu, search, }: {
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
17
  mobileMenu?: React.ReactNode;
18
+ /**
19
+ * Search control, rendered to the left of the theme toggle.
20
+ *
21
+ * In the header rather than in page content because the alternative
22
+ * put it on exactly one page type per site: the provider site's home
23
+ * page, and the user docs site's section landing pages, which are the
24
+ * pages being removed. Every content page on both sites had no way to
25
+ * search at all.
26
+ */
27
+ search?: React.ReactNode;
18
28
  }): import("react").JSX.Element;
package/dist/Header.js CHANGED
@@ -1,13 +1,13 @@
1
1
  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
- export function Header({ nav, tabs, activeTab, mobileMenu, }) {
4
+ export function Header({ nav, tabs, activeTab, mobileMenu, search, }) {
5
5
  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) => {
6
6
  const className = item.current
7
7
  ? "text-sm text-primary"
8
8
  : "text-sm text-foreground-muted hover:text-primary";
9
9
  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));
10
- }) }), _jsx(ThemeToggle, {})] }), 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) => {
10
+ }) }), search ? _jsx("div", { className: "w-40 shrink-0 sm:w-56 lg:w-64", children: search }) : null, _jsx(ThemeToggle, {})] }), 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
11
  // Prefix match, so /concepts/ledger keeps the Concepts tab
12
12
  // lit rather than only the exact section index.
13
13
  const active = activeTab === tab.href || activeTab?.startsWith(tab.href + "/");
@@ -1,3 +1,8 @@
1
- export declare function MobileSidebarToggle({ children }: {
1
+ export declare function MobileSidebarToggle({ children, label, }: {
2
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;
3
8
  }): import("react").JSX.Element;
@@ -18,14 +18,29 @@ import { useEffect, useRef, useState } from "react";
18
18
  // vanishing the instant the tap registers.
19
19
  const TRANSITION_MS = 200;
20
20
  // UBI-247: takes the drawer contents as `children` rather than
21
- // constructing a ProviderSidebar itself. That coupling was the one thing
22
- // in this component that was not generic, and it is exactly the kind of
23
- // thing that would have been carried into @ubx/docs-ui unnoticed had the
24
- // extraction been done first against a single call site. The provider
25
- // site passes <ProviderSidebar .../>, this site passes <DocSidebar .../>,
26
- // and the open/close/transition/route-change behaviour below is
27
- // identical for both.
28
- export function MobileSidebarToggle({ children }) {
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, }) {
29
44
  const [mounted, setMounted] = useState(false);
30
45
  const [visible, setVisible] = useState(false);
31
46
  const closeTimer = useRef(null);
@@ -68,8 +83,8 @@ export function MobileSidebarToggle({ children }) {
68
83
  close();
69
84
  // eslint-disable-next-line react-hooks/exhaustive-deps
70
85
  }, [pathname]);
71
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: open, "aria-label": "Open service 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 service navigation", onClick: close, className: "absolute inset-0 bg-foreground/40 transition-opacity duration-200 motion-reduce:transition-none " +
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 " +
72
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 " +
73
88
  "transition-transform duration-200 ease-out motion-reduce:transition-none " +
74
- (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: "Services" }), _jsx("button", { type: "button", onClick: close, "aria-label": "Close service 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] })] }))] }));
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] })] }))] }));
75
90
  }
@@ -0,0 +1,54 @@
1
+ import type React from "react";
2
+ import { type NavLink, type SectionTab } from "./Header";
3
+ export type PageShellProps = {
4
+ nav: NavLink[];
5
+ /** Omit for a single-tier header. The provider site omits it. */
6
+ tabs?: SectionTab[];
7
+ /** href of the active tab, prefix-matched so nested pages stay lit. */
8
+ activeTab?: string;
9
+ /**
10
+ * Sidebar tree. When present the shell renders the two-column rail and
11
+ * wires the same node into the mobile drawer, so the desktop and mobile
12
+ * navigation can never disagree about what they contain.
13
+ */
14
+ sidebar?: React.ReactNode;
15
+ /** Drawer heading, in the consuming site's own vocabulary. */
16
+ sidebarLabel?: string;
17
+ /**
18
+ * Search placeholder. Presence of this prop is what mounts search.
19
+ *
20
+ * The placeholder is a prop because it is genuinely site-specific
21
+ * ("Search resources and data sources..." against "Search the docs").
22
+ * The input's styling is NOT a prop, which reverses the decision
23
+ * GlobalSearch made when it was extracted. That decision was right at
24
+ * the time: the two sites styled the box differently, and a default
25
+ * would have silently imposed one site's look on the other. Now that
26
+ * the shell owns the placement, one styling is the correct answer for
27
+ * both, and leaving it configurable would just re-open the drift.
28
+ */
29
+ searchPlaceholder?: string;
30
+ /** Footer identity. Required for the reason stated on Footer itself. */
31
+ footer: {
32
+ tagline: React.ReactNode;
33
+ links: {
34
+ label: string;
35
+ href: string;
36
+ }[];
37
+ };
38
+ children: React.ReactNode;
39
+ };
40
+ export declare function PageShell({ nav, tabs, activeTab, sidebar, sidebarLabel, searchPlaceholder, footer, children, }: PageShellProps): React.JSX.Element;
41
+ /**
42
+ * Sets data-theme from localStorage before paint.
43
+ *
44
+ * Both sites carried this as a verbatim string constant in their own root
45
+ * layout, which is duplication the package could not see. It has to be an
46
+ * exported string rather than a component because it goes in <head> via
47
+ * dangerouslySetInnerHTML and must run ahead of hydration: a React
48
+ * component would run too late and the reader would see a flash of the
49
+ * OS default before their stored choice applied.
50
+ *
51
+ * Absent or invalid storage leaves no attribute at all, which is exactly
52
+ * "follow the OS".
53
+ */
54
+ 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})();";
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Header } from "./Header";
3
+ import { Footer } from "./Footer";
4
+ import { GlobalSearch } from "./GlobalSearch";
5
+ import { MobileSidebarToggle } from "./MobileSidebarToggle";
6
+ /** Shared header search styling. See searchPlaceholder above. */
7
+ const SEARCH_INPUT_CLASS = "w-full rounded-full bg-field px-4 py-1.5 text-sm text-foreground outline-none " +
8
+ "placeholder:text-foreground-muted focus:ring-2 focus:ring-primary/30";
9
+ export function PageShell({ nav, tabs, activeTab, sidebar, sidebarLabel = "Navigation", searchPlaceholder, footer, children, }) {
10
+ const search = searchPlaceholder ? (_jsx(GlobalSearch, { placeholder: searchPlaceholder, inputClassName: SEARCH_INPUT_CLASS })) : undefined;
11
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { nav: nav, tabs: tabs, activeTab: activeTab, search: search, mobileMenu: sidebar ? (_jsx(MobileSidebarToggle, { label: sidebarLabel, children: sidebar })) : undefined }), sidebar ? (_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 })] })) : (_jsx("main", { className: "mx-auto w-full max-w-7xl flex-1 px-6 py-12", children: children })), _jsx(Footer, { tagline: footer.tagline, links: footer.links })] }));
12
+ }
13
+ /**
14
+ * Sets data-theme from localStorage before paint.
15
+ *
16
+ * Both sites carried this as a verbatim string constant in their own root
17
+ * layout, which is duplication the package could not see. It has to be an
18
+ * exported string rather than a component because it goes in <head> via
19
+ * dangerouslySetInnerHTML and must run ahead of hydration: a React
20
+ * component would run too late and the reader would see a flash of the
21
+ * OS default before their stored choice applied.
22
+ *
23
+ * Absent or invalid storage leaves no attribute at all, which is exactly
24
+ * "follow the OS".
25
+ */
26
+ export const THEME_INIT_SCRIPT = `(function () {
27
+ try {
28
+ var stored = window.localStorage.getItem("ubx-docs-theme");
29
+ if (stored === "light" || stored === "dark") {
30
+ document.documentElement.setAttribute("data-theme", stored);
31
+ }
32
+ } catch (e) {}
33
+ })();`;
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export type { SearchEntry } from "./GlobalSearch";
8
8
  export { ThemeToggle } from "./ThemeToggle";
9
9
  export { Footer } from "./Footer";
10
10
  export { MobileSidebarToggle } from "./MobileSidebarToggle";
11
+ export { PageShell, THEME_INIT_SCRIPT } from "./PageShell";
12
+ export type { PageShellProps } from "./PageShell";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export { GlobalSearch } from "./GlobalSearch";
14
14
  export { ThemeToggle } from "./ThemeToggle";
15
15
  export { Footer } from "./Footer";
16
16
  export { MobileSidebarToggle } from "./MobileSidebarToggle";
17
+ export { PageShell, THEME_INIT_SCRIPT } from "./PageShell";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ubx/docs-ui",
3
- "version": "0.2.0",
4
- "description": "Shared UI for ubx documentation sites: theme A, Shiki-backed CodeBlock, header, search, theme toggle.",
3
+ "version": "0.4.0",
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",
7
7
  "files": [
@@ -16,9 +16,11 @@
16
16
  }
17
17
  },
18
18
  "scripts": {
19
- "build": "tsc -p tsconfig.json",
19
+ "build": "rm -rf dist && tsc -p tsconfig.json",
20
20
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
- "prepublishOnly": "npm run build"
21
+ "prepublishOnly": "npm run build",
22
+ "test": "node --test \"test/**/*.test.mjs\"",
23
+ "check:package": "npm run build && node scripts/check-package.mjs"
22
24
  },
23
25
  "peerDependencies": {
24
26
  "next": ">=15",