@zccz14/ux 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zheng Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # @zccz14/ux
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@zccz14/ux` provides the shared `AppLayout` shell for the NTNL app family (Cybion, NormAI, CTX, Linkit, Midas, HIT, 1Exchange, DeepSeek-LB, OpenAI-LB). One implementation of the sidebar, the AppHeader, and the Linkit account control keeps the interaction identical across apps and replaces the per-app copies of that chrome.
4
+
5
+ ## What the shell owns
6
+
7
+ - **Sidebar** — one animated icon-collapse sidebar: 16rem expanded, 3rem collapsed, 200 ms width transition. Midas, 1Exchange and 1earn-style shells without animation are replaced by this behavior.
8
+ - **Navigation groups only** — the sidebar has no separate bottom section. Settings and status destinations live in the navigation groups like every other destination, so the bottom edge of every app's sidebar looks the same.
9
+ - **AppHeader** — sidebar trigger, current page title, your `headerSlot`, and the Linkit account control (`LinkitMyInfo`), in that order. Role badges such as 超级管理员 stay out of the shell: administrator affordances live in the administrator navigation group and pages.
10
+ - **Logo** — you pass the light and dark logo marks; the shell renders the active variant inside a fixed 1.75rem box and controls the display size itself, so the logo size is identical everywhere. The title beside the logo disappears while collapsed.
11
+ - **Linkit integration** — the signed-in viewer's `resolvedTheme` picks the logo variant, the viewer's language selects the shell copy (中文/English), and theme, language and account controls all live in the `LinkitMyInfo` dialog.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ npm install @zccz14/ux
17
+ ```
18
+
19
+ Peer dependencies: `react` (18 or 19), `react-dom`, `react-router-dom` 7, `@base-ui/react` ≥ 1.6, `linkit-react-components` ≥ 0.5.1 (older versions do not publish `resolvedTheme`).
20
+
21
+ ## Usage
22
+
23
+ ```tsx
24
+ import { AuthMiniProvider } from "auth-mini-react-components";
25
+ import { LinkitProvider } from "linkit-react-components";
26
+ import { AppLayout } from "@zccz14/ux";
27
+ import { MessagesSquareIcon, ServerIcon } from "lucide-react";
28
+ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
29
+
30
+ import "@zccz14/ux/styles.css";
31
+ import "linkit-react-components/styles.css";
32
+ import { CybionMark } from "./cybion-mark";
33
+
34
+ export function App() {
35
+ return (
36
+ <AuthMiniProvider
37
+ authMiniBaseUrl="https://auth.ntnl.io"
38
+ audiences={["cybion.ntnl.io", "linkit.ntnl.io"]}
39
+ autoRedirectToLogin
40
+ >
41
+ <LinkitProvider linkitBaseUrl="https://linkit.ntnl.io" lang="zh-CN">
42
+ <HashRouter>
43
+ <AppLayout
44
+ logo={{ light: <CybionMark />, dark: <CybionMark /> }}
45
+ title="Cybion"
46
+ nav={[
47
+ {
48
+ label: "工作台",
49
+ items: [
50
+ { to: "/threads", label: "对话", icon: <MessagesSquareIcon /> },
51
+ { to: "/system", label: "系统资源", icon: <ServerIcon /> },
52
+ ],
53
+ },
54
+ ]}
55
+ headerSlot={<RefreshButton />}
56
+ >
57
+ <Routes>
58
+ <Route path="/threads" element={<ThreadsPage />} />
59
+ <Route path="*" element={<Navigate replace to="/threads" />} />
60
+ </Routes>
61
+ </AppLayout>
62
+ </HashRouter>
63
+ </LinkitProvider>
64
+ </AuthMiniProvider>
65
+ );
66
+ }
67
+ ```
68
+
69
+ `AppLayout` must render below `AuthMiniProvider → LinkitProvider` (it renders `LinkitMyInfo` and reads `useLinkit()`), and inside a router (navigation items render router links).
70
+
71
+ ## API
72
+
73
+ ### `AppLayout` props
74
+
75
+ | Prop | Type | Required | Description |
76
+ | --- | --- | --- | --- |
77
+ | `logo` | `{ light: ReactNode; dark: ReactNode }` | yes | The two logo variants. The shell renders the one that matches `useLinkit().resolvedTheme` at its own fixed size. |
78
+ | `title` | `string` | yes | App name beside the logo. Hidden while the sidebar is collapsed. |
79
+ | `nav` | `AppNavGroup[]` | yes | Navigation groups rendered in the sidebar. |
80
+ | `headerSlot` | `ReactNode` | no | Rendered immediately left of `LinkitMyInfo` in the AppHeader. Use it for page-level actions such as refresh. |
81
+ | `pageTitle` | `ReactNode` | no | Overrides the page title derived from the active navigation item. Use it for detail routes with their own title. |
82
+ | `children` | `ReactNode` | yes | Page content, rendered in the scrollable main region. |
83
+
84
+ ### Navigation types
85
+
86
+ ```ts
87
+ type AppNavItem = { to: string; label: string; icon: ReactNode };
88
+ type AppNavGroup = { label: string; items: AppNavItem[] };
89
+ ```
90
+
91
+ `to` is a router path. Each item renders a `NavLink`, so active items receive `aria-current="page"` and the standard active styling. Route matching uses longest-prefix wins, and `/` matches only exactly.
92
+
93
+ ## Behavior
94
+
95
+ - **Page title** — the AppHeader shows the active navigation item's `label`, matched from `location.pathname`. `pageTitle` wins when supplied.
96
+ - **Collapse** — the header trigger or `⌘B` / `Ctrl+B` toggles the sidebar; the choice persists in the `sidebar_state` cookie.
97
+ - **Mobile** — below 768px the sidebar becomes a modal navigation drawer: the trigger opens it, tapping a link or the backdrop closes it.
98
+ - **Collapsed tooltips** — icon-only items reveal their label in a tooltip on hover and focus.
99
+ - **Reduced motion** — `prefers-reduced-motion: reduce` disables the animations.
100
+ - **Layout contract** — the shell is viewport-locked (`100svh`); the main region is the page's only scroll container and applies the standard `1rem` / `1.5rem` padding. Pages own their inner layout.
101
+
102
+ ## Styling
103
+
104
+ Import `@zccz14/ux/styles.css` once. It consumes the app's shadcn theme tokens with built-in fallbacks: `--background`, `--foreground`, `--border`, `--accent`, `--muted`, `--ring`, `--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`.
105
+
106
+ ## Migrating an app from its local shell
107
+
108
+ 1. Replace the local `SidebarProvider` / `Sidebar` / `AppHeader` copy with one `AppLayout` at the top of `App.tsx`.
109
+ 2. Move legacy header buttons (refresh, settings, language, theme) into `headerSlot`. Role badges are dropped: administrator affordances belong to the administrator navigation group and pages, and theme/language preferences live in the `LinkitMyInfo` profile dialog (`linkit-react-components` 0.5+).
110
+ 3. Move any sidebar footer item (settings, environment status) into the last navigation group.
111
+ 4. Pass the logo pair and delete per-call sizes; the shell normalizes every mark to its 1.75rem box.
112
+
113
+ ## Development
114
+
115
+ ```sh
116
+ npm install
117
+ npm run typecheck
118
+ npm test
119
+ npm run build
120
+ npm run pack:check
121
+ ```
@@ -0,0 +1,2 @@
1
+ import type { AppLayoutProps } from "./types.js";
2
+ export declare function AppLayout({ children, headerSlot, logo, nav, pageTitle, title, }: AppLayoutProps): import("react").JSX.Element;
package/dist/copy.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type AppLayoutCopy = {
2
+ closeNavigation: string;
3
+ navigation: string;
4
+ toggleSidebar: string;
5
+ };
6
+ export declare function copyForLang(lang: string): AppLayoutCopy;
@@ -0,0 +1,2 @@
1
+ export declare function CloseIcon(): import("react").JSX.Element;
2
+ export declare function PanelLeftIcon(): import("react").JSX.Element;
@@ -0,0 +1,2 @@
1
+ export { AppLayout } from "./app-layout.js";
2
+ export type { AppLayoutProps, AppLogo, AppNavGroup, AppNavItem } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,247 @@
1
+ import { jsxs as s, jsx as e, Fragment as _ } from "react/jsx-runtime";
2
+ import { useState as f, useCallback as g, useEffect as y, useSyncExternalStore as w } from "react";
3
+ import { Dialog as h } from "@base-ui/react/dialog";
4
+ import { Tooltip as p } from "@base-ui/react/tooltip";
5
+ import { useLinkit as v, LinkitMyInfo as M } from "linkit-react-components";
6
+ import { useLocation as L, NavLink as S } from "react-router-dom";
7
+ const C = {
8
+ closeNavigation: "Close navigation",
9
+ navigation: "Navigation",
10
+ toggleSidebar: "Toggle sidebar"
11
+ }, O = {
12
+ closeNavigation: "关闭导航",
13
+ navigation: "导航",
14
+ toggleSidebar: "切换侧边栏"
15
+ };
16
+ function P(n) {
17
+ return n.toLowerCase().startsWith("zh") ? O : C;
18
+ }
19
+ function T() {
20
+ return /* @__PURE__ */ s(
21
+ "svg",
22
+ {
23
+ "aria-hidden": "true",
24
+ fill: "none",
25
+ stroke: "currentColor",
26
+ strokeLinecap: "round",
27
+ strokeLinejoin: "round",
28
+ strokeWidth: "2",
29
+ viewBox: "0 0 24 24",
30
+ children: [
31
+ /* @__PURE__ */ e("path", { d: "M18 6 6 18" }),
32
+ /* @__PURE__ */ e("path", { d: "m6 6 12 12" })
33
+ ]
34
+ }
35
+ );
36
+ }
37
+ function E() {
38
+ return /* @__PURE__ */ s(
39
+ "svg",
40
+ {
41
+ "aria-hidden": "true",
42
+ fill: "none",
43
+ stroke: "currentColor",
44
+ strokeLinecap: "round",
45
+ strokeLinejoin: "round",
46
+ strokeWidth: "2",
47
+ viewBox: "0 0 24 24",
48
+ children: [
49
+ /* @__PURE__ */ e("rect", { height: "18", rx: "2", width: "18", x: "3", y: "3" }),
50
+ /* @__PURE__ */ e("path", { d: "M9 3v18" })
51
+ ]
52
+ }
53
+ );
54
+ }
55
+ const m = "sidebar_state", I = 3600 * 24 * 7, N = "(max-width: 767px)";
56
+ function W() {
57
+ const n = document.cookie.split("; ").find((a) => a.startsWith(`${m}=`));
58
+ return n ? n.slice(m.length + 1) !== "false" : !0;
59
+ }
60
+ function $(n) {
61
+ document.cookie = `${m}=${n}; path=/; max-age=${I}`;
62
+ }
63
+ function j(n) {
64
+ const a = window.matchMedia(N);
65
+ return a.addEventListener("change", n), () => a.removeEventListener("change", n);
66
+ }
67
+ function B() {
68
+ return w(
69
+ j,
70
+ () => window.matchMedia(N).matches,
71
+ () => !1
72
+ );
73
+ }
74
+ function A() {
75
+ const n = B(), [a, t] = f(W), [c, r] = f(!1), d = g((i) => {
76
+ t(i), $(i);
77
+ }, []), u = g(() => {
78
+ if (n) {
79
+ r((i) => !i);
80
+ return;
81
+ }
82
+ d(!a);
83
+ }, [n, a, d]);
84
+ return y(() => {
85
+ function i(o) {
86
+ (o.metaKey || o.ctrlKey) && o.key === "b" && (o.preventDefault(), u());
87
+ }
88
+ return window.addEventListener("keydown", i), () => window.removeEventListener("keydown", i);
89
+ }, [u]), { isMobile: n, open: a, openMobile: c, setOpenMobile: r, toggleSidebar: u };
90
+ }
91
+ function D(n, a) {
92
+ let t;
93
+ for (const c of n)
94
+ for (const r of c.items)
95
+ (r.to === "/" ? a === "/" : a === r.to || a.startsWith(`${r.to}/`)) && (!t || r.to.length > t.to.length) && (t = r);
96
+ return t;
97
+ }
98
+ function K({
99
+ collapsed: n,
100
+ item: a,
101
+ onNavigate: t
102
+ }) {
103
+ return /* @__PURE__ */ s(p.Root, { children: [
104
+ /* @__PURE__ */ s(
105
+ p.Trigger,
106
+ {
107
+ render: /* @__PURE__ */ e(
108
+ S,
109
+ {
110
+ className: "ux-nav-item",
111
+ end: a.to === "/",
112
+ onClick: t,
113
+ to: a.to
114
+ }
115
+ ),
116
+ children: [
117
+ /* @__PURE__ */ e("span", { className: "ux-nav-item__icon", children: a.icon }),
118
+ /* @__PURE__ */ e("span", { className: "ux-nav-item__text", children: a.label })
119
+ ]
120
+ }
121
+ ),
122
+ /* @__PURE__ */ e(p.Portal, { children: /* @__PURE__ */ e(
123
+ p.Positioner,
124
+ {
125
+ className: "ux-tooltip-positioner",
126
+ side: "right",
127
+ sideOffset: 6,
128
+ children: /* @__PURE__ */ e(p.Popup, { className: "ux-tooltip", hidden: !n, children: a.label })
129
+ }
130
+ ) })
131
+ ] });
132
+ }
133
+ function x({
134
+ copy: n,
135
+ logo: a,
136
+ nav: t,
137
+ onNavigate: c,
138
+ state: r,
139
+ title: d
140
+ }) {
141
+ const { resolvedTheme: u } = v();
142
+ return /* @__PURE__ */ s(_, { children: [
143
+ /* @__PURE__ */ s("div", { className: "ux-sidebar__header", children: [
144
+ /* @__PURE__ */ e("span", { className: "ux-sidebar__logo", children: u === "dark" ? a.dark : a.light }),
145
+ /* @__PURE__ */ e("span", { className: "ux-sidebar__title", children: d })
146
+ ] }),
147
+ /* @__PURE__ */ e("nav", { "aria-label": n.navigation, className: "ux-sidebar__nav", children: t.map((i) => /* @__PURE__ */ s("div", { className: "ux-nav-group", children: [
148
+ /* @__PURE__ */ e("div", { className: "ux-nav-group__label", children: i.label }),
149
+ i.items.map((o) => /* @__PURE__ */ e(
150
+ K,
151
+ {
152
+ collapsed: r === "collapsed",
153
+ item: o,
154
+ onNavigate: c
155
+ },
156
+ o.to
157
+ ))
158
+ ] }, i.label)) })
159
+ ] });
160
+ }
161
+ function H({
162
+ children: n,
163
+ headerSlot: a,
164
+ logo: t,
165
+ nav: c,
166
+ pageTitle: r,
167
+ title: d
168
+ }) {
169
+ var b;
170
+ const { pathname: u } = L(), { lang: i } = v(), o = P(i), l = A(), k = r ?? ((b = D(c, u)) == null ? void 0 : b.label);
171
+ return /* @__PURE__ */ e(p.Provider, { delay: 0, children: /* @__PURE__ */ s(
172
+ "div",
173
+ {
174
+ className: "ux-app-layout",
175
+ "data-state": l.open ? "expanded" : "collapsed",
176
+ children: [
177
+ /* @__PURE__ */ e("aside", { className: "ux-sidebar", children: /* @__PURE__ */ e(
178
+ x,
179
+ {
180
+ copy: o,
181
+ logo: t,
182
+ nav: c,
183
+ state: l.open ? "expanded" : "collapsed",
184
+ title: d
185
+ }
186
+ ) }),
187
+ l.isMobile ? /* @__PURE__ */ e(
188
+ h.Root,
189
+ {
190
+ open: l.openMobile,
191
+ onOpenChange: l.setOpenMobile,
192
+ children: /* @__PURE__ */ s(h.Portal, { children: [
193
+ /* @__PURE__ */ e(h.Backdrop, { className: "ux-drawer-backdrop" }),
194
+ /* @__PURE__ */ s(h.Popup, { className: "ux-drawer", children: [
195
+ /* @__PURE__ */ e(h.Title, { className: "ux-visually-hidden", children: o.navigation }),
196
+ /* @__PURE__ */ e(
197
+ "button",
198
+ {
199
+ "aria-label": o.closeNavigation,
200
+ className: "ux-drawer__close",
201
+ type: "button",
202
+ onClick: () => l.setOpenMobile(!1),
203
+ children: /* @__PURE__ */ e(T, {})
204
+ }
205
+ ),
206
+ /* @__PURE__ */ e(
207
+ x,
208
+ {
209
+ copy: o,
210
+ logo: t,
211
+ nav: c,
212
+ onNavigate: () => l.setOpenMobile(!1),
213
+ state: "expanded",
214
+ title: d
215
+ }
216
+ )
217
+ ] })
218
+ ] })
219
+ }
220
+ ) : null,
221
+ /* @__PURE__ */ s("div", { className: "ux-shell", children: [
222
+ /* @__PURE__ */ s("header", { className: "ux-app-header", children: [
223
+ /* @__PURE__ */ e(
224
+ "button",
225
+ {
226
+ "aria-expanded": l.isMobile ? l.openMobile : l.open,
227
+ "aria-label": o.toggleSidebar,
228
+ className: "ux-app-header__trigger",
229
+ type: "button",
230
+ onClick: l.toggleSidebar,
231
+ children: /* @__PURE__ */ e(E, {})
232
+ }
233
+ ),
234
+ /* @__PURE__ */ e("span", { "aria-hidden": "true", className: "ux-app-header__separator" }),
235
+ /* @__PURE__ */ e("p", { className: "ux-app-header__title", children: k }),
236
+ /* @__PURE__ */ e("div", { className: "ux-app-header__slot", children: a }),
237
+ /* @__PURE__ */ e(M, {})
238
+ ] }),
239
+ /* @__PURE__ */ e("main", { className: "ux-main", children: n })
240
+ ] })
241
+ ]
242
+ }
243
+ ) });
244
+ }
245
+ export {
246
+ H as AppLayout
247
+ };
@@ -0,0 +1,418 @@
1
+ /* @zccz14/ux — unified AppLayout shell. Include this file once from the consuming application. */
2
+
3
+ .ux-app-layout {
4
+ display: flex;
5
+ height: 100svh;
6
+ overflow: hidden;
7
+ color: var(--foreground, #0f172a);
8
+ background: var(--background, #fff);
9
+ }
10
+
11
+ /* --- Sidebar --- */
12
+
13
+ .ux-sidebar {
14
+ display: flex;
15
+ flex: none;
16
+ flex-direction: column;
17
+ width: 16rem;
18
+ overflow: hidden;
19
+ color: var(--sidebar-foreground, var(--foreground, #0f172a));
20
+ background: var(--sidebar, #fafafa);
21
+ border-right: 1px solid var(--sidebar-border, #e2e8f0);
22
+ transition: width 200ms linear;
23
+ }
24
+
25
+ .ux-app-layout[data-state="collapsed"] .ux-sidebar {
26
+ width: 3rem;
27
+ }
28
+
29
+ .ux-sidebar__header {
30
+ box-sizing: border-box;
31
+ display: flex;
32
+ flex: none;
33
+ align-items: center;
34
+ gap: 0.5rem;
35
+ height: 3.5rem;
36
+ padding: 0 0.75rem;
37
+ transition: padding 200ms linear;
38
+ }
39
+
40
+ .ux-app-layout[data-state="collapsed"] .ux-sidebar__header {
41
+ justify-content: center;
42
+ padding: 0;
43
+ }
44
+
45
+ .ux-drawer .ux-sidebar__header {
46
+ padding-right: 3rem;
47
+ }
48
+
49
+ /* The framework owns the logo display size: both logo variants render inside a fixed 1.75rem box. */
50
+ .ux-sidebar__logo {
51
+ display: flex;
52
+ flex: none;
53
+ align-items: center;
54
+ justify-content: center;
55
+ width: 1.75rem;
56
+ height: 1.75rem;
57
+ }
58
+
59
+ .ux-sidebar__logo svg,
60
+ .ux-sidebar__logo img {
61
+ display: block;
62
+ width: 100%;
63
+ height: 100%;
64
+ }
65
+
66
+ .ux-sidebar__title {
67
+ min-width: 0;
68
+ overflow: hidden;
69
+ font-size: 0.875rem;
70
+ font-weight: 600;
71
+ line-height: 1.25;
72
+ white-space: nowrap;
73
+ transition: opacity 150ms linear;
74
+ }
75
+
76
+ .ux-app-layout[data-state="collapsed"] .ux-sidebar__title {
77
+ opacity: 0;
78
+ }
79
+
80
+ .ux-sidebar__nav {
81
+ display: flex;
82
+ flex: 1;
83
+ flex-direction: column;
84
+ gap: 0.25rem;
85
+ min-height: 0;
86
+ padding: 0.5rem;
87
+ overflow-x: hidden;
88
+ overflow-y: auto;
89
+ }
90
+
91
+ .ux-nav-group {
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 0.125rem;
95
+ }
96
+
97
+ .ux-nav-group__label {
98
+ display: flex;
99
+ flex: none;
100
+ align-items: center;
101
+ height: 2rem;
102
+ padding: 0 0.5rem;
103
+ overflow: hidden;
104
+ font-size: 0.75rem;
105
+ font-weight: 500;
106
+ white-space: nowrap;
107
+ opacity: 0.7;
108
+ transition: margin-top 200ms linear, opacity 200ms linear;
109
+ }
110
+
111
+ .ux-app-layout[data-state="collapsed"] .ux-nav-group__label {
112
+ margin-top: -2rem;
113
+ opacity: 0;
114
+ }
115
+
116
+ .ux-nav-item {
117
+ box-sizing: border-box;
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 0.5rem;
121
+ width: 100%;
122
+ height: 2rem;
123
+ padding: 0 0.5rem;
124
+ overflow: hidden;
125
+ color: inherit;
126
+ font-size: 0.875rem;
127
+ line-height: 1.25;
128
+ text-decoration: none;
129
+ border-radius: 0.375rem;
130
+ outline: none;
131
+ transition:
132
+ background-color 150ms ease-out,
133
+ color 150ms ease-out,
134
+ padding 200ms linear;
135
+ }
136
+
137
+ .ux-nav-item:hover {
138
+ color: var(--sidebar-accent-foreground, #0f172a);
139
+ background: var(--sidebar-accent, #f1f5f9);
140
+ }
141
+
142
+ .ux-nav-item:focus-visible {
143
+ outline: 2px solid var(--sidebar-ring, #2563eb);
144
+ outline-offset: -2px;
145
+ }
146
+
147
+ .ux-nav-item[aria-current="page"] {
148
+ color: var(--sidebar-accent-foreground, #0f172a);
149
+ font-weight: 500;
150
+ background: var(--sidebar-accent, #f1f5f9);
151
+ }
152
+
153
+ .ux-app-layout[data-state="collapsed"] .ux-nav-item {
154
+ justify-content: center;
155
+ gap: 0;
156
+ width: 2rem;
157
+ padding: 0;
158
+ }
159
+
160
+ .ux-nav-item__icon {
161
+ display: flex;
162
+ flex: none;
163
+ align-items: center;
164
+ justify-content: center;
165
+ }
166
+
167
+ .ux-nav-item__icon svg {
168
+ display: block;
169
+ width: 1rem;
170
+ height: 1rem;
171
+ }
172
+
173
+ .ux-nav-item__text {
174
+ min-width: 0;
175
+ overflow: hidden;
176
+ white-space: nowrap;
177
+ transition: opacity 150ms linear;
178
+ }
179
+
180
+ .ux-app-layout[data-state="collapsed"] .ux-nav-item__text {
181
+ opacity: 0;
182
+ }
183
+
184
+ @media (max-width: 767px) {
185
+ .ux-sidebar {
186
+ display: none;
187
+ }
188
+ }
189
+
190
+ /* --- App header and main content --- */
191
+
192
+ .ux-shell {
193
+ display: flex;
194
+ flex: 1;
195
+ flex-direction: column;
196
+ min-width: 0;
197
+ }
198
+
199
+ .ux-app-header {
200
+ box-sizing: border-box;
201
+ display: flex;
202
+ flex: none;
203
+ align-items: center;
204
+ gap: 0.75rem;
205
+ height: 3.5rem;
206
+ padding: 0 1rem;
207
+ background: var(--background, #fff);
208
+ border-bottom: 1px solid var(--border, #e2e8f0);
209
+ }
210
+
211
+ .ux-app-header__trigger {
212
+ display: inline-flex;
213
+ flex: none;
214
+ align-items: center;
215
+ justify-content: center;
216
+ width: 2rem;
217
+ height: 2rem;
218
+ padding: 0;
219
+ font: inherit;
220
+ color: inherit;
221
+ cursor: pointer;
222
+ background: transparent;
223
+ border: 0;
224
+ border-radius: 0.375rem;
225
+ outline: none;
226
+ transition: background-color 150ms ease-out;
227
+ }
228
+
229
+ .ux-app-header__trigger:hover {
230
+ background: var(--accent, var(--muted, #f1f5f9));
231
+ }
232
+
233
+ .ux-app-header__trigger:focus-visible {
234
+ outline: 2px solid var(--ring, #2563eb);
235
+ outline-offset: 2px;
236
+ }
237
+
238
+ .ux-app-header__trigger svg {
239
+ width: 1rem;
240
+ height: 1rem;
241
+ }
242
+
243
+ .ux-app-header__separator {
244
+ flex: none;
245
+ width: 1px;
246
+ height: 1.25rem;
247
+ background: var(--border, #e2e8f0);
248
+ }
249
+
250
+ .ux-app-header__title {
251
+ flex: 1;
252
+ min-width: 0;
253
+ margin: 0;
254
+ overflow: hidden;
255
+ font-size: 0.875rem;
256
+ font-weight: 500;
257
+ line-height: 1.25;
258
+ text-overflow: ellipsis;
259
+ white-space: nowrap;
260
+ }
261
+
262
+ .ux-app-header__slot {
263
+ display: flex;
264
+ flex: none;
265
+ align-items: center;
266
+ gap: 0.5rem;
267
+ }
268
+
269
+ .ux-main {
270
+ box-sizing: border-box;
271
+ display: flex;
272
+ flex: 1;
273
+ flex-direction: column;
274
+ min-height: 0;
275
+ padding: 1rem;
276
+ overflow-y: auto;
277
+ }
278
+
279
+ /* --- Mobile navigation drawer --- */
280
+
281
+ .ux-drawer-backdrop {
282
+ position: fixed;
283
+ inset: 0;
284
+ z-index: 50;
285
+ background: rgb(0 0 0 / 0.45);
286
+ transition: opacity 200ms ease-out;
287
+ }
288
+
289
+ .ux-drawer-backdrop[data-starting-style],
290
+ .ux-drawer-backdrop[data-ending-style] {
291
+ opacity: 0;
292
+ }
293
+
294
+ .ux-drawer {
295
+ position: fixed;
296
+ top: 0;
297
+ bottom: 0;
298
+ left: 0;
299
+ z-index: 50;
300
+ display: flex;
301
+ flex-direction: column;
302
+ width: 18rem;
303
+ max-width: calc(100vw - 3rem);
304
+ overflow: hidden;
305
+ color: var(--sidebar-foreground, var(--foreground, #0f172a));
306
+ background: var(--sidebar, #fafafa);
307
+ border-right: 1px solid var(--sidebar-border, #e2e8f0);
308
+ outline: none;
309
+ transition: transform 200ms ease-out, opacity 200ms ease-out;
310
+ }
311
+
312
+ .ux-drawer[data-starting-style],
313
+ .ux-drawer[data-ending-style] {
314
+ opacity: 0;
315
+ transform: translateX(-2.5rem);
316
+ }
317
+
318
+ .ux-drawer__close {
319
+ position: absolute;
320
+ top: 0.75rem;
321
+ right: 0.75rem;
322
+ display: inline-flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ width: 2rem;
326
+ height: 2rem;
327
+ padding: 0;
328
+ font: inherit;
329
+ color: inherit;
330
+ cursor: pointer;
331
+ background: transparent;
332
+ border: 0;
333
+ border-radius: 0.375rem;
334
+ transition: background-color 150ms ease-out;
335
+ }
336
+
337
+ .ux-drawer__close:hover {
338
+ background: var(--sidebar-accent, #f1f5f9);
339
+ }
340
+
341
+ .ux-drawer__close:focus-visible {
342
+ outline: 2px solid var(--sidebar-ring, #2563eb);
343
+ outline-offset: 2px;
344
+ }
345
+
346
+ .ux-drawer__close svg {
347
+ width: 1rem;
348
+ height: 1rem;
349
+ }
350
+
351
+ /* --- Collapsed navigation tooltip --- */
352
+
353
+ .ux-tooltip-positioner {
354
+ z-index: 60;
355
+ }
356
+
357
+ .ux-tooltip {
358
+ box-sizing: border-box;
359
+ display: inline-flex;
360
+ align-items: center;
361
+ max-width: 16rem;
362
+ padding: 0.375rem 0.75rem;
363
+ color: var(--background, #fff);
364
+ font-size: 0.75rem;
365
+ font-weight: 500;
366
+ line-height: 1.25;
367
+ white-space: nowrap;
368
+ background: var(--foreground, #0f172a);
369
+ border-radius: 0.375rem;
370
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
371
+ transition: opacity 120ms ease-out, transform 120ms ease-out;
372
+ }
373
+
374
+ .ux-tooltip[hidden] {
375
+ display: none;
376
+ }
377
+
378
+ .ux-tooltip[data-starting-style],
379
+ .ux-tooltip[data-ending-style] {
380
+ opacity: 0;
381
+ transform: scale(0.96);
382
+ }
383
+
384
+ .ux-visually-hidden {
385
+ position: absolute;
386
+ width: 1px;
387
+ height: 1px;
388
+ padding: 0;
389
+ margin: -1px;
390
+ overflow: hidden;
391
+ clip: rect(0, 0, 0, 0);
392
+ white-space: nowrap;
393
+ border: 0;
394
+ }
395
+
396
+ @media (min-width: 768px) {
397
+ .ux-app-header {
398
+ padding: 0 1.5rem;
399
+ }
400
+
401
+ .ux-main {
402
+ padding: 1.5rem;
403
+ }
404
+ }
405
+
406
+ @media (prefers-reduced-motion: reduce) {
407
+ .ux-sidebar,
408
+ .ux-sidebar__header,
409
+ .ux-sidebar__title,
410
+ .ux-nav-group__label,
411
+ .ux-nav-item,
412
+ .ux-nav-item__text,
413
+ .ux-drawer,
414
+ .ux-drawer-backdrop,
415
+ .ux-tooltip {
416
+ transition: none;
417
+ }
418
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ export type AppLogo = {
3
+ light: ReactNode;
4
+ dark: ReactNode;
5
+ };
6
+ export type AppNavItem = {
7
+ to: string;
8
+ label: string;
9
+ icon: ReactNode;
10
+ };
11
+ export type AppNavGroup = {
12
+ label: string;
13
+ items: AppNavItem[];
14
+ };
15
+ export type AppLayoutProps = {
16
+ logo: AppLogo;
17
+ title: string;
18
+ nav: AppNavGroup[];
19
+ headerSlot?: ReactNode;
20
+ pageTitle?: ReactNode;
21
+ children: ReactNode;
22
+ };
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@zccz14/ux",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Unified AppLayout shell for the NTNL app family: one animated icon sidebar, a fixed-size dark/light logo, and the Linkit-integrated AppHeader.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zccz14/ux.git"
9
+ },
10
+ "homepage": "https://github.com/zccz14/ux#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/zccz14/ux/issues"
13
+ },
14
+ "type": "module",
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./styles.css": "./dist/styles.css"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "engines": {
32
+ "node": ">=20.10.0"
33
+ },
34
+ "scripts": {
35
+ "build": "vite build && tsc -p tsconfig.build.json && cp src/styles.css dist/styles.css",
36
+ "test": "vitest run && node scripts/check-styles.mjs",
37
+ "typecheck": "tsc --noEmit",
38
+ "pack:check": "npm pack --dry-run"
39
+ },
40
+ "peerDependencies": {
41
+ "@base-ui/react": "^1.6.0",
42
+ "linkit-react-components": "^0.5.1",
43
+ "react": "^18.0.0 || ^19.0.0",
44
+ "react-dom": "^18.0.0 || ^19.0.0",
45
+ "react-router-dom": "^7.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@base-ui/react": "^1.6.0",
49
+ "@testing-library/jest-dom": "^6.9.1",
50
+ "@testing-library/react": "^16.3.0",
51
+ "@types/react": "^19.1.16",
52
+ "@types/react-dom": "^19.1.9",
53
+ "auth-mini-react-components": "^0.6.10",
54
+ "jsdom": "^26.1.0",
55
+ "linkit-react-components": "^0.5.1",
56
+ "lucide-react": "^1.31.0",
57
+ "react": "^19.1.1",
58
+ "react-dom": "^19.1.1",
59
+ "react-router-dom": "^7.18.1",
60
+ "typescript": "^5.9.3",
61
+ "vite": "^6.3.5",
62
+ "vitest": "^3.2.4"
63
+ }
64
+ }