@viliha/vui-ui 1.4.1 → 1.5.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/AGENT.md CHANGED
@@ -76,11 +76,18 @@ command palette, nav config, logo) and **demo pages** are scaffolded with:
76
76
  npx @viliha/vui-ui init # interactive decision tree
77
77
  ```
78
78
 
79
- Files land in the consumer's repo (they own them). Two questions:
79
+ Files land in the consumer's repo (they own them). Questions:
80
+ 0. **Next.js or Turborepo?** (`--nextjs` / `--turbo` + `--dir <path>`, default
81
+ `apps/web` — turbo scaffolds into that app directory)
80
82
  1. **Fresh project?** (`--fresh` / `--existing`)
81
83
  2. **Pre-built theme?** (`--prebuilt` = shell + demo pages / `--theme-only` = just
82
84
  the theme wiring you configure)
83
85
 
86
+ For a fresh app, create it **without `--src-dir`** (the scaffold uses a root
87
+ `app/` + `@/*` → `./*` and writes `next.config.ts`). `init` **auto-installs the
88
+ dependencies** with the package manager it detects from the lockfile (npm / pnpm
89
+ / yarn / bun); `--yes` skips the prompt, `--no-install` skips installing.
90
+
84
91
  - **fresh + prebuilt** → full runnable app (config + shell + demo).
85
92
  - **fresh + theme-only** → just `globals.css` + `next.config` wiring; build your own.
86
93
  - **existing + prebuilt** → shell + demo added; config **never** overwritten —
package/README.md CHANGED
@@ -61,23 +61,51 @@ Files are copied into *your* repo, so you own and edit them.
61
61
  | **Fresh** | full runnable app: config + shell + demo pages | just the theme wiring (`globals.css`, `next.config`) — build your own pages |
62
62
  | **Existing** | shell + demo added; your config is **never** overwritten (prints merge steps) | nothing copied; prints the wiring steps |
63
63
 
64
+ It first asks whether this is a standalone **Next.js** app or a **Turborepo**
65
+ (for a monorepo it scaffolds into a target app dir, e.g. `apps/web`).
66
+
64
67
  ```
65
68
  Flags (for CI / agents, skip the prompts):
69
+ --nextjs | --turbo standalone app, or a Turborepo (Q0)
70
+ --dir <path> Turborepo target app dir (default apps/web)
66
71
  --fresh | --existing project type (Q1)
67
72
  --prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
68
- --yes, -y accept defaults (fresh, prebuilt)
73
+ --yes, -y accept defaults (nextjs, fresh, prebuilt)
69
74
  --force overwrite existing files
70
75
  --dry-run preview without writing
71
76
  ```
72
77
 
73
- After a **pre-built** run, install the peer deps it prints, then `npm run dev` →
74
- `/dashboard`.
78
+ `init` **auto-installs the dependencies** with your package manager (detected
79
+ from the lockfile — npm, pnpm, yarn, or bun). It prompts first; pass `--yes` to
80
+ skip, or `--no-install` to do it yourself.
75
81
 
76
82
  ### Fresh + pre-built (recommended for new apps)
77
83
 
78
- Start from a `create-next-app` base. This writes `next.config.mjs`,
79
- `app/globals.css`, the shell, and the demo pages (overwriting the create-next-app
80
- boilerplate), so the demo runs out of the box.
84
+ Start from a `create-next-app` base **without `--src-dir`** (the scaffold uses a
85
+ root `app/` + `@/*` → `./*`), then run `init` it scaffolds, installs deps, and
86
+ you're ready for `dev`. Use whichever package manager you like:
87
+
88
+ ```bash
89
+ # npm
90
+ npx create-next-app@latest my-app --ts --tailwind --app --no-src-dir --use-npm
91
+ cd my-app && npx @viliha/vui-ui init && npm run dev
92
+
93
+ # pnpm
94
+ pnpm create next-app my-app --ts --tailwind --app --no-src-dir
95
+ cd my-app && pnpm dlx @viliha/vui-ui init && pnpm dev
96
+
97
+ # yarn
98
+ yarn create next-app my-app --ts --tailwind --app --no-src-dir
99
+ cd my-app && yarn dlx @viliha/vui-ui init && yarn dev
100
+
101
+ # bun
102
+ bun create next-app my-app --ts --tailwind --app --no-src-dir
103
+ cd my-app && bunx @viliha/vui-ui init && bun dev
104
+ ```
105
+
106
+ `init` writes `next.config.ts`, `tsconfig.json`, `app/globals.css`, the shell,
107
+ and the demo pages (overwriting the create-next-app boilerplate) and installs the
108
+ deps, so the demo runs out of the box at `/dashboard`.
81
109
 
82
110
  ### ⚠️ Existing project — read this first
83
111
 
package/bin/vui.mjs CHANGED
@@ -16,6 +16,7 @@ import { dirname, join, relative, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createInterface } from "node:readline/promises";
18
18
  import { stdin, stdout } from "node:process";
19
+ import { execSync } from "node:child_process";
19
20
 
20
21
  const TEMPLATE = fileURLToPath(new URL("../template/", import.meta.url));
21
22
  const args = process.argv.slice(2);
@@ -42,13 +43,11 @@ const DEPS = [
42
43
  "recharts",
43
44
  "sonner",
44
45
  "tailwind-merge",
45
- // theme's globals.css imports these:
46
- "tw-animate-css",
47
46
  "zod",
48
47
  ].join(" ");
49
48
 
50
49
  const WIRING_STEPS = ` 1. Install peer deps (those your components use):
51
- npm i ${DEPS}
50
+ npm i @viliha/vui-ui ${DEPS}
52
51
  2. app/globals.css:
53
52
  @import "tailwindcss";
54
53
  @import "@viliha/vui-ui/theme.css";
@@ -98,7 +97,7 @@ async function ask(question, def) {
98
97
 
99
98
  // Theme wiring config an existing project already has — never clobbered there.
100
99
  const WIRING = new Set([
101
- "next.config.mjs",
100
+ "next.config.ts",
102
101
  "postcss.config.mjs",
103
102
  ".env.example",
104
103
  "app/globals.css",
@@ -123,6 +122,21 @@ function category(rel) {
123
122
  return "shell";
124
123
  }
125
124
 
125
+ // Detect the package manager from lockfiles (falls back to npm).
126
+ function detectPM(root) {
127
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
128
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
129
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
130
+ return "bun";
131
+ return "npm";
132
+ }
133
+ const ADD_CMD = {
134
+ npm: "npm install",
135
+ pnpm: "pnpm add",
136
+ yarn: "yarn add",
137
+ bun: "bun add",
138
+ };
139
+
126
140
  function allFiles(dir, out = []) {
127
141
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
128
142
  const abs = join(dir, entry.name);
@@ -225,12 +239,46 @@ async function main() {
225
239
  `\nDone — ${created} file(s) ${dry ? "would be added" : "added"}, ${skipped} skipped.`,
226
240
  );
227
241
 
242
+ // Install dependencies. Turborepo installs are workspace-specific, so we only
243
+ // auto-install for a standalone Next.js app; turbo gets manual instructions.
244
+ const pm = detectPM(targetRoot);
245
+ const installList = `@viliha/vui-ui ${DEPS}`;
246
+ let installed = false;
247
+ if (!turbo) {
248
+ let doInstall = has("--no-install")
249
+ ? false
250
+ : has("--yes") || has("-y")
251
+ ? true
252
+ : null;
253
+ if (doInstall === null) {
254
+ const a = await ask(`Install dependencies now with ${pm}? [Y/n] `, "y");
255
+ doInstall = !a.startsWith("n");
256
+ }
257
+ if (doInstall && !dry) {
258
+ console.log(`\nInstalling dependencies with ${pm}…\n`);
259
+ try {
260
+ execSync(`${ADD_CMD[pm]} ${installList}`, {
261
+ cwd: targetRoot,
262
+ stdio: "inherit",
263
+ });
264
+ installed = true;
265
+ } catch {
266
+ console.error(
267
+ `\nInstall failed — run it manually:\n ${ADD_CMD[pm]} ${installList}\n`,
268
+ );
269
+ }
270
+ }
271
+ }
272
+
228
273
  // Per-leaf next steps.
274
+ const installStep = installed
275
+ ? ""
276
+ : ` 1. ${ADD_CMD[pm]} ${installList}\n`;
277
+ const devStepNum = installed ? "1" : "2";
229
278
  if (fresh && prebuilt) {
230
279
  console.log(`
231
280
  Next steps:
232
- 1. npm i ${DEPS}
233
- 2. npm run dev -> http://localhost:3000 (redirects to /dashboard)
281
+ ${installStep} ${devStepNum}. ${pm === "npm" ? "npm run dev" : `${pm} dev`} -> http://localhost:3000 (redirects to /dashboard)
234
282
 
235
283
  Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
236
284
  (NEXT_PUBLIC_LOGO_URL in .env), and delete demo pages you don't need.
@@ -240,8 +288,7 @@ Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
240
288
  Theme wired (globals.css + next.config). No shell or demo pages — build your own.
241
289
 
242
290
  Next steps:
243
- 1. npm i ${DEPS}
244
- 2. Import components from @viliha/vui-ui/* in your pages, then: npm run dev
291
+ ${installStep} ${devStepNum}. Import components from @viliha/vui-ui/* in your pages, then run dev.
245
292
  `);
246
293
  } else {
247
294
  // existing + prebuilt
@@ -260,10 +307,10 @@ The shell + demo were added under app/(app)/ and app/_components/ — review the
260
307
 
261
308
  if (turbo) {
262
309
  console.log(`Turborepo notes (target: ${appDir}):
263
- • Add "@viliha/vui-ui" to ${appDir}/package.json dependencies.
310
+ • Add the deps to that app: ${ADD_CMD[pm]} ${installList}
311
+ (from the app dir, or with your PM's workspace filter).
264
312
  • Ensure your workspace globs include this app (pnpm-workspace.yaml / workspaces).
265
- • Run install + dev from the repo root or with a filter, e.g.
266
- pnpm --filter <app-name> dev
313
+ • Run dev with a filter, e.g. ${pm} --filter <app-name> dev
267
314
  `);
268
315
  }
269
316
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -18,3 +18,10 @@ NEXT_PUBLIC_LICENSE="MIT Licensed"
18
18
  # Max pages kept open in the tab strip (client-side). Default 5, min 1. Opening
19
19
  # more evicts the oldest tab with a warning.
20
20
  NEXT_PUBLIC_MAX_TABS="5"
21
+
22
+ # How a collapsed sidebar rail reveals a group's sub-items. One of:
23
+ # inline — expands underneath the icon, in the rail itself
24
+ # flyout-click — click the icon to open a floating panel beside the rail
25
+ # flyout-hover — hover the icon to open it (click still works too)
26
+ # Default: flyout-hover
27
+ NEXT_PUBLIC_SIDEBAR_GROUP_MODE="flyout-hover"
@@ -13,6 +13,7 @@ import {
13
13
  } from "@radix-ui/react-icons";
14
14
 
15
15
  import { cn } from "@/lib/utils";
16
+ import { Menu as MenuPanel } from "@viliha/vui-ui/menu";
16
17
  import { Logo } from "./logo";
17
18
  import { QuickActionsLauncher } from "./quick-actions";
18
19
  import { useOpenTabs } from "./open-tabs";
@@ -29,6 +30,25 @@ function isActive(pathname: string, href: string): boolean {
29
30
  return pathname === href || pathname.startsWith(`${href}/`);
30
31
  }
31
32
 
33
+ /** How a collapsed-rail group with sub-items reveals its children. Set via
34
+ * `NEXT_PUBLIC_SIDEBAR_GROUP_MODE` (build-time) — see .env.example. */
35
+ type CollapsedGroupMode = "inline" | "flyout-click" | "flyout-hover";
36
+ const COLLAPSED_GROUP_MODES: readonly CollapsedGroupMode[] = [
37
+ "inline",
38
+ "flyout-click",
39
+ "flyout-hover",
40
+ ];
41
+ function readCollapsedGroupMode(): CollapsedGroupMode {
42
+ const raw = process.env.NEXT_PUBLIC_SIDEBAR_GROUP_MODE;
43
+ return (COLLAPSED_GROUP_MODES as readonly string[]).includes(raw ?? "")
44
+ ? (raw as CollapsedGroupMode)
45
+ : "flyout-hover";
46
+ }
47
+ const GROUP_MODE = readCollapsedGroupMode();
48
+ /** Delay before a hover-flyout closes, so the cursor can travel diagonally
49
+ * from the trigger into the panel without it disappearing mid-move. */
50
+ const FLYOUT_HOVER_CLOSE_DELAY_MS = 150;
51
+
32
52
  /** Nav glyph. All icons share one size for a consistent left-aligned column.
33
53
  Top-level icons are bordered chips (from the global icon rule): inactive keep
34
54
  their brand color, active inverts (dark fill, light glyph). Sub-menu icons are
@@ -95,17 +115,109 @@ function SidebarBody({
95
115
  }
96
116
  return open;
97
117
  });
118
+ const usesFlyout = collapsed && GROUP_MODE !== "inline";
119
+
98
120
  const toggleGroup = (label: string) =>
99
121
  setOpenGroups((prev) => {
122
+ // Flyout modes show one group at a time (menu-style); inline mode lets
123
+ // several collapsed groups stay expanded at once, like the expanded rail.
124
+ if (usesFlyout) return prev.has(label) ? new Set() : new Set([label]);
100
125
  const next = new Set(prev);
101
126
  if (next.has(label)) next.delete(label);
102
127
  else next.add(label);
103
128
  return next;
104
129
  });
105
130
 
131
+ // flyout-hover: open on enter, close on leave after a short delay so the
132
+ // cursor can travel from the trigger into the panel. A pending close is
133
+ // cancelled if the pointer re-enters the trigger or the panel first.
134
+ const hoverCloseTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
135
+ const cancelHoverClose = () => {
136
+ if (hoverCloseTimer.current) {
137
+ clearTimeout(hoverCloseTimer.current);
138
+ hoverCloseTimer.current = null;
139
+ }
140
+ };
141
+ const scheduleHoverClose = () => {
142
+ cancelHoverClose();
143
+ hoverCloseTimer.current = setTimeout(() => {
144
+ setOpenGroups(new Set());
145
+ }, FLYOUT_HOVER_CLOSE_DELAY_MS);
146
+ };
147
+ const openGroupOnHover = (label: string) => {
148
+ cancelHoverClose();
149
+ setOpenGroups(new Set([label]));
150
+ };
151
+ React.useEffect(() => () => cancelHoverClose(), []);
152
+
153
+ // Flyout modes: the open group's panel renders `position: fixed` at a rect
154
+ // computed from its trigger button, so it escapes the nav's
155
+ // `overflow-y-auto` clipping instead of being cut off at the rail edge.
156
+ const navRef = React.useRef<HTMLElement | null>(null);
157
+ const flyoutRef = React.useRef<HTMLDivElement | null>(null);
158
+ const groupButtonRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
159
+ const [flyoutPos, setFlyoutPos] = React.useState<{ top: number; left: number } | null>(null);
160
+ const openGroupLabel = usesFlyout ? Array.from(openGroups)[0] : undefined;
161
+
162
+ React.useLayoutEffect(() => {
163
+ if (!openGroupLabel) {
164
+ setFlyoutPos(null);
165
+ return;
166
+ }
167
+ const btn = groupButtonRefs.current.get(openGroupLabel);
168
+ if (!btn) return;
169
+ const rect = btn.getBoundingClientRect();
170
+ setFlyoutPos({ top: rect.top, left: rect.right + 4 });
171
+ }, [openGroupLabel]);
172
+
173
+ // Clamp the panel to the viewport once its real height is known — a group
174
+ // near the bottom of the rail would otherwise render partly (or fully)
175
+ // below the fold and be invisible.
176
+ React.useLayoutEffect(() => {
177
+ if (!flyoutPos || !flyoutRef.current) return;
178
+ const margin = 8;
179
+ const rect = flyoutRef.current.getBoundingClientRect();
180
+ const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
181
+ if (rect.top > maxTop) {
182
+ setFlyoutPos((prev) => (prev ? { ...prev, top: maxTop } : prev));
183
+ }
184
+ }, [flyoutPos]);
185
+
186
+ React.useEffect(() => {
187
+ if (!openGroupLabel) return;
188
+ const close = () => setOpenGroups(new Set());
189
+ const onPointerDown = (e: MouseEvent) => {
190
+ const target = e.target as Node;
191
+ if (navRef.current?.contains(target)) return;
192
+ if (flyoutRef.current?.contains(target)) return;
193
+ close();
194
+ };
195
+ const onKeyDown = (e: KeyboardEvent) => {
196
+ if (e.key === "Escape") close();
197
+ };
198
+ document.addEventListener("mousedown", onPointerDown);
199
+ document.addEventListener("keydown", onKeyDown);
200
+ window.addEventListener("resize", close);
201
+ return () => {
202
+ document.removeEventListener("mousedown", onPointerDown);
203
+ document.removeEventListener("keydown", onKeyDown);
204
+ window.removeEventListener("resize", close);
205
+ };
206
+ }, [openGroupLabel]);
207
+
208
+ const openGroupEntry = React.useMemo(() => {
209
+ if (!openGroupLabel) return undefined;
210
+ for (const section of NAV) {
211
+ for (const entry of section.items) {
212
+ if (isGroup(entry) && entry.label === openGroupLabel) return entry;
213
+ }
214
+ }
215
+ return undefined;
216
+ }, [openGroupLabel]);
217
+
106
218
  const { openTab } = useOpenTabs();
107
219
 
108
- const renderLink = (item: NavLink, sub = false) => {
220
+ const renderLink = (item: NavLink, sub = false, inFlyout = false) => {
109
221
  const active = isActive(pathname, item.href);
110
222
  const onClick = (e: React.MouseEvent) => {
111
223
  // ⌘/Ctrl+click → open in a background tab (stay on the current page),
@@ -115,27 +227,30 @@ function SidebarBody({
115
227
  openTab(item.href, { background: true });
116
228
  return;
117
229
  }
230
+ setOpenGroups((prev) => (collapsed && prev.size ? new Set() : prev));
118
231
  onNavigate?.();
119
232
  };
233
+ const showLabel = inFlyout || !collapsed;
120
234
  return (
121
235
  <Link
122
236
  key={item.href}
123
237
  href={item.href}
124
238
  onClick={onClick}
125
239
  aria-current={active ? "page" : undefined}
126
- title={collapsed ? `${item.label} — ⌘-click for a new tab` : undefined}
240
+ title={collapsed && !inFlyout ? `${item.label} — ⌘-click for a new tab` : undefined}
127
241
  className={cn(
128
- "group/nav flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
242
+ "group/nav flex h-9 items-center rounded-md transition-colors",
243
+ collapsed && !inFlyout ? "w-9 justify-center px-0" : "gap-2.5 px-2",
129
244
  active
130
245
  ? "bg-sidebar-accent text-sidebar-foreground"
131
246
  : cn(
132
247
  "hover:bg-sidebar-accent hover:text-sidebar-foreground",
133
- sub ? "text-muted-foreground" : "text-sidebar-foreground",
248
+ sub && !inFlyout ? "text-muted-foreground" : "text-sidebar-foreground",
134
249
  ),
135
250
  )}
136
251
  >
137
- <NavIcon icon={item.icon} active={active} color={item.color} plain={sub} />
138
- {!collapsed && <span className="truncate">{item.label}</span>}
252
+ <NavIcon icon={item.icon} active={active} color={item.color} plain={sub && !inFlyout} />
253
+ {showLabel && <span className="truncate">{item.label}</span>}
139
254
  </Link>
140
255
  );
141
256
  };
@@ -147,9 +262,10 @@ function SidebarBody({
147
262
  const anyActive = entry.children.some((c) => isActive(pathname, c.href));
148
263
  const GroupIcon = entry.icon;
149
264
 
150
- // Collapsed rail: show the PARENT icon with an expand chevron beside it;
151
- // its children reveal inline (indented, smaller) when toggled open.
152
- if (collapsed)
265
+ // Collapsed rail — inline mode: the icon plus a small corner chevron
266
+ // badge (not inline beside it, so it never overflows the icon's own box);
267
+ // children reveal indented underneath when toggled open.
268
+ if (collapsed && GROUP_MODE === "inline")
153
269
  return (
154
270
  <div key={entry.label} className="space-y-1">
155
271
  <button
@@ -158,16 +274,14 @@ function SidebarBody({
158
274
  aria-expanded={open}
159
275
  title={entry.label}
160
276
  className={cn(
161
- "group/nav flex w-full items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
162
- anyActive
163
- ? "text-sidebar-foreground"
164
- : "hover:bg-sidebar-accent",
277
+ "group/nav relative flex size-9 items-center justify-center rounded-md transition-colors",
278
+ anyActive ? "text-sidebar-foreground" : "hover:bg-sidebar-accent",
165
279
  )}
166
280
  >
167
281
  <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
168
282
  <ChevronRight
169
283
  className={cn(
170
- "size-3 shrink-0 text-muted-foreground transition-transform",
284
+ "absolute right-0.5 top-1/2 size-2.5 shrink-0 -translate-y-1/2 text-muted-foreground transition-transform",
171
285
  open && "rotate-90",
172
286
  )}
173
287
  aria-hidden="true"
@@ -181,6 +295,37 @@ function SidebarBody({
181
295
  </div>
182
296
  );
183
297
 
298
+ // Collapsed rail — flyout modes: the icon alone represents the group
299
+ // (matching the leaf icons around it, no overlapping chevron); opening
300
+ // its children panel (rendered separately, fixed-positioned) is either
301
+ // click- or hover-triggered depending on GROUP_MODE.
302
+ if (collapsed)
303
+ return (
304
+ <button
305
+ key={entry.label}
306
+ ref={(el) => {
307
+ if (el) groupButtonRefs.current.set(entry.label, el);
308
+ else groupButtonRefs.current.delete(entry.label);
309
+ }}
310
+ type="button"
311
+ onClick={() => toggleGroup(entry.label)}
312
+ onMouseEnter={
313
+ GROUP_MODE === "flyout-hover" ? () => openGroupOnHover(entry.label) : undefined
314
+ }
315
+ onMouseLeave={GROUP_MODE === "flyout-hover" ? scheduleHoverClose : undefined}
316
+ aria-expanded={open}
317
+ aria-haspopup="menu"
318
+ title={entry.label}
319
+ className={cn(
320
+ "group/nav flex size-9 items-center justify-center rounded-md transition-colors",
321
+ open && "bg-sidebar-accent",
322
+ anyActive ? "text-sidebar-foreground" : "hover:bg-sidebar-accent",
323
+ )}
324
+ >
325
+ <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
326
+ </button>
327
+ );
328
+
184
329
  return (
185
330
  <div key={entry.label} className="space-y-1">
186
331
  <button
@@ -188,7 +333,7 @@ function SidebarBody({
188
333
  onClick={() => toggleGroup(entry.label)}
189
334
  aria-expanded={open}
190
335
  className={cn(
191
- "group/nav flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
336
+ "group/nav flex h-9 w-full items-center gap-2.5 rounded-md px-2 transition-colors",
192
337
  anyActive
193
338
  ? "text-sidebar-foreground"
194
339
  : "text-sidebar-foreground hover:bg-sidebar-accent",
@@ -220,8 +365,8 @@ function SidebarBody({
220
365
  <button
221
366
  type="button"
222
367
  className={cn(
223
- "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent",
224
- collapsed && "justify-center px-0",
368
+ "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent",
369
+ collapsed ? "w-9 shrink-0 justify-center px-0" : "flex-1",
225
370
  )}
226
371
  aria-label="Switch workspace"
227
372
  title={collapsed ? "Vui Starter" : undefined}
@@ -241,17 +386,12 @@ function SidebarBody({
241
386
 
242
387
  {/* Quick actions — fixed above the scrolling nav, with a full-width
243
388
  divider and comfortable top/bottom padding. */}
244
- <div
245
- className={cn(
246
- "border-b border-sidebar-border px-3 py-3",
247
- collapsed && "flex justify-center",
248
- )}
249
- >
389
+ <div className="border-b border-sidebar-border px-3 py-3">
250
390
  <QuickActionsLauncher collapsed={collapsed} />
251
391
  </div>
252
392
 
253
393
  {/* Navigation */}
254
- <nav className="flex-1 space-y-4 overflow-y-auto px-3 py-3">
394
+ <nav ref={navRef} className="flex-1 space-y-4 overflow-y-auto px-3 py-3">
255
395
  {NAV.map((section, index) => (
256
396
  <React.Fragment key={section.title ?? `section-${index}`}>
257
397
  {/* Collapsed group separator — a nav-level sibling so the nav's
@@ -274,6 +414,28 @@ function SidebarBody({
274
414
  ))}
275
415
  </nav>
276
416
 
417
+ {/* Collapsed-group flyout: fixed-positioned beside its trigger so it
418
+ escapes the nav's overflow-y-auto clipping instead of the old
419
+ inline-expand, which overflowed the rail and clipped the chevron. */}
420
+ {openGroupEntry && flyoutPos && (
421
+ <div
422
+ ref={flyoutRef}
423
+ style={{ top: flyoutPos.top, left: flyoutPos.left }}
424
+ onMouseEnter={GROUP_MODE === "flyout-hover" ? cancelHoverClose : undefined}
425
+ onMouseLeave={GROUP_MODE === "flyout-hover" ? scheduleHoverClose : undefined}
426
+ className="fixed z-50 min-w-44"
427
+ >
428
+ <MenuPanel
429
+ tabIndex={-1}
430
+ className="space-y-0.5 border-sidebar-border bg-sidebar p-1 shadow-lg"
431
+ >
432
+ <p className="truncate px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">
433
+ {openGroupEntry.label}
434
+ </p>
435
+ {openGroupEntry.children.map((child) => renderLink(child, false, true))}
436
+ </MenuPanel>
437
+ </div>
438
+ )}
277
439
  </>
278
440
  );
279
441
  }
@@ -124,15 +124,17 @@ export function QuickActionsLauncher({ collapsed = false }: { collapsed?: boolea
124
124
  aria-label="Quick actions"
125
125
  title="Quick actions (⌘K)"
126
126
  className={cn(
127
- "flex items-center gap-2 rounded-md border border-sidebar-border bg-background text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
128
- collapsed ? "size-9 justify-center px-0" : "w-full px-2.5 py-2",
127
+ "flex h-9 items-center gap-2 whitespace-nowrap rounded-md border border-sidebar-border bg-background text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
128
+ collapsed ? "w-9 justify-center px-0" : "w-full px-2.5",
129
129
  )}
130
130
  >
131
131
  <LightningBoltIcon className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
132
132
  {!collapsed && (
133
133
  <>
134
- <span className="flex-1 text-left text-sm font-medium">Quick actions</span>
135
- <Shortcut keys={["⌘", "K"]} />
134
+ <span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
135
+ Quick actions
136
+ </span>
137
+ <Shortcut keys={["⌘", "K"]} className="shrink-0" />
136
138
  </>
137
139
  )}
138
140
  </button>
@@ -1,5 +1,4 @@
1
1
  @import "tailwindcss";
2
- @import "tw-animate-css";
3
2
 
4
3
  /* Shared VUI Starter design system (tokens, theme mapping, base reset).
5
4
  Package path (not a relative one) so it survives folder moves. */
@@ -1,5 +1,6 @@
1
- /** @type {import('next').NextConfig} */
2
- const nextConfig = {
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
3
4
  reactStrictMode: true,
4
5
  // VUI ships as TypeScript source — Next must transpile it.
5
6
  transpilePackages: ["@viliha/vui-ui"],