@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.68

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.
Files changed (36) hide show
  1. package/.agents/rules/components.md +1 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/client/components/page-header.tsx +1 -1
  4. package/client/components/status-badge.tsx +7 -1
  5. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  6. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  7. package/client/index.tsx +4 -0
  8. package/client/layouts/components/app-sidebar.tsx +5 -5
  9. package/client/layouts/components/layout-header.tsx +1 -1
  10. package/client/layouts/components/navigation-menu.tsx +223 -12
  11. package/client/layouts/components/sidebar-footer.tsx +1 -1
  12. package/client/layouts/components/user-menu.tsx +1 -1
  13. package/client/lib/navigation-transform.ts +8 -0
  14. package/client/lib/scrollbar.ts +88 -0
  15. package/client/locales/system/en-US.ts +4 -0
  16. package/client/locales/system/zh-CN.ts +15 -12
  17. package/client/pages/auth/auth-page.css +37 -0
  18. package/client/pages/auth/reset-password.tsx +57 -14
  19. package/client/pages/auth/shared.tsx +24 -24
  20. package/client/pages/home/index.tsx +2 -2
  21. package/client/routing/route-navigation.ts +10 -1
  22. package/client/styles.css +253 -17
  23. package/client/theme/theme-settings.tsx +12 -7
  24. package/client/theme/themes/compact.css +9 -14
  25. package/client/theme/themes/default.css +7 -12
  26. package/package.json +7 -7
  27. package/server/app.ts +2 -0
  28. package/server/config/notification.ts +4 -7
  29. package/tests/components/auth-i18n.test.tsx +57 -5
  30. package/tests/components/layout-i18n.test.tsx +2 -2
  31. package/tests/components/navigation-menu.test.tsx +133 -4
  32. package/tests/components/sidebar-permissions.test.tsx +5 -15
  33. package/tests/components/status-badge.test.tsx +22 -0
  34. package/tests/logic/scrollbar.test.ts +88 -0
  35. package/tests/logic/theme-tokens.test.ts +1 -0
  36. package/vitest.config.ts +1 -0
@@ -29,6 +29,6 @@ Style with the semantic Tailwind tokens — `bg-background`, `text-foreground`,
29
29
 
30
30
  **Visual consistency is a whole-application property.** Match the surrounding code's spacing, typography, and component choices. If a change genuinely calls for a different look, change the application's design tokens in `client/theme/themes/*.css` so every page moves together. Never restyle only the part you are working on — a page that looks different from the rest is a defect, not a customization.
31
31
 
32
- The default brand palette and density live in `client/theme/themes/*.css`; shared hover/cursor and Badge geometry live in `client/styles.css`, outside generated shadcn primitives. For semantic status labels, use `StatusBadge` from `client/components/status-badge.tsx` with `neutral`, `info`, `warning`, or `success`; choose the tone by meaning and keep the visible status text. Reuse these shared components and tokens in new pages so template upgrades can carry the design consistently.
32
+ The default brand palette and density live in `client/theme/themes/*.css`; shared hover/cursor and Badge geometry live in `client/styles.css`, outside generated shadcn primitives. For semantic status labels, use `StatusBadge` from `client/components/status-badge.tsx` with `neutral`, `info`, `warning`, or `success`; choose the tone by meaning and keep the visible status text. A badge beside a title or on a dense card is the small one, `size='sm'` on `StatusBadge` or `data-size='sm'` on `Badge`. Reuse these shared components and tokens in new pages so template upgrades can carry the design consistently.
33
33
 
34
34
  **Follow the UI guidelines.** [The UI guidelines](../skills/nocobase-app-development/references/frontend/ui-guidelines.md) set the conventions every page follows, among them the page header and the single placement of the primary action, empty states, row actions behind one "…" menu, dates picked from a `Calendar` in a `Popover` rather than a native date input, and medium-width drawers; the frontend workflow says how much of it to read for a change.
@@ -8,10 +8,12 @@ Layouts own breadcrumb route context and render the current page's trail in the
8
8
 
9
9
  ## Header and sidebar
10
10
 
11
- `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*` and controlled `Collapsible` sub-menus. Leave `client/components/ui/sidebar.tsx` as the shadcn CLI writes it and customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed` across applications on the same origin; whether the phone sheet is open stays local to each layout.
11
+ `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*`: a top-level group with no page of its own is a section, its entries always shown under a `SidebarGroupLabel` heading, while clickable parents and nested groups are controlled `Collapsible` sub-menus, and an item `client/lib/navigation-transform.ts` marks `section` deeper in the tree is a heading over its direct links inside that menu; a count beside an entry, such as unread items, is passed by the App layout in `NavigationMenu`'s `badges`, keyed by route name. Leave `client/components/ui/sidebar.tsx` as the shadcn CLI writes it and customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode hides top-level section headings and keeps their entries' icons, shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed` across applications on the same origin; whether the phone sheet is open stays local to each layout.
12
12
 
13
13
  The header carries no Settings or Inbox entry. An application has no separate settings area: a page that configures something is an ordinary route in `client/routes.ts` with `navigation`, placed in the sidebar like any other page, for example under a Settings navigation group. The `/inbox` page declares no navigation; give it an entry where the application wants one.
14
14
 
15
+ `client/lib/navigation-transform.ts` is the application-owned hook for customizing the permission-filtered navigation tree. The shared sidebar supports static section labels with direct links as well as collapsible groups.
16
+
15
17
  Navigation groups retain their expanded or collapsed state while the navigation tree stays mounted. Selecting a new page expands its ancestor groups without collapsing other groups; users can still collapse the active group manually.
16
18
 
17
19
  ## Account menu and session
@@ -16,7 +16,7 @@ export function PageHeader({
16
16
  return (
17
17
  <header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
18
18
  <div className='min-w-0'>
19
- <h1 className='font-heading text-3xl font-semibold tracking-[-0.035em]'>
19
+ <h1 className='font-heading text-2xl font-semibold tracking-[-0.035em]'>
20
20
  {title}
21
21
  </h1>
22
22
  {description ? (
@@ -11,17 +11,23 @@ const tones = {
11
11
  'border-(--status-success)/20 bg-(--status-success-background) text-(--status-success)',
12
12
  };
13
13
 
14
- /** Quiet semantic status labels; reserve solid primary fills for actions. */
14
+ /**
15
+ * Quiet semantic status labels; reserve solid primary fills for actions. `size='sm'` is the small badge, for a status
16
+ * beside a title or on a dense card (`data-size` in `client/styles.css`).
17
+ */
15
18
  export function StatusBadge({
16
19
  tone = 'neutral',
20
+ size,
17
21
  className,
18
22
  ...props
19
23
  }: Omit<ComponentProps<typeof Badge>, 'variant'> & {
20
24
  readonly tone?: keyof typeof tones;
25
+ readonly size?: 'sm';
21
26
  }): ReactElement {
22
27
  return (
23
28
  <Badge
24
29
  {...props}
30
+ data-size={size}
25
31
  variant='outline'
26
32
  className={cn(tones[tone], className)}
27
33
  />
@@ -1,8 +1,4 @@
1
- import {
2
- CheckCircle2Icon,
3
- ChevronRightIcon,
4
- MoreHorizontalIcon,
5
- } from 'lucide-react';
1
+ import { CheckCircle2Icon, MoreHorizontalIcon } from 'lucide-react';
6
2
  import { createElement, type KeyboardEvent, type ReactElement } from 'react';
7
3
 
8
4
  import { Button } from '#components/ui/button';
@@ -99,11 +95,12 @@ export function InboxItemCard(props: InboxItemCardProps): ReactElement {
99
95
  }
100
96
 
101
97
  /**
102
- * One compact card of the inbox list. Clicking anywhere on it shows it in the detail pane (and marks it read), as its
103
- * chevron says; Enter on a focused card opens its route. The card reads: what it is about (an issue's identifier), type, ×N and time, then its title,
104
- * then one sentence, as its contributor words them. A decision that still waits carries an amber bar, an unread card a
105
- * dot and a bold title, a settled decision is dimmed with a check, and an item that arrived after the list loaded
106
- * slides in. The menu button and a right-click offer read / unread and delete.
98
+ * One compact card of the inbox list. Clicking anywhere on it shows it in the detail pane (and marks it read); Enter on
99
+ * a focused card opens its route. The card reads: its title, ×N and time, then one line of particulars: whether it was
100
+ * handled, its type, what it is about (an issue's identifier) and one sentence, as its contributor words them. A
101
+ * decision that still waits carries an amber bar, an unread card a fainter bar and a bold title, a settled decision is
102
+ * dimmed with a check, and an item that arrived after the list loaded slides in. The menu button, in the time's place
103
+ * while the card is hovered, and a right-click offer read / unread and delete.
107
104
  */
108
105
  function ItemCard({
109
106
  entry,
@@ -158,8 +155,10 @@ function ItemCard({
158
155
  data-inbox-item={item.id}
159
156
  data-selected={selected ? 'true' : undefined}
160
157
  className={cn(
161
- 'group relative flex items-start gap-3 rounded-lg border border-transparent p-3 pr-2 transition-[background-color,border-color,opacity] duration-200',
162
- selected ? 'border-border bg-card shadow-xs' : 'hover:bg-accent/60',
158
+ // Pulled out by its padding and border, so its words start where the tabs' labels do and its bar sits in the
159
+ // list's gutter.
160
+ 'group relative -mx-[calc(--spacing(3)+1px)] flex items-start gap-3 rounded-lg border border-transparent p-3 transition-[background-color,border-color,opacity] duration-200',
161
+ selected ? 'bg-hover' : 'hover:bg-hover',
163
162
  settled && 'opacity-55',
164
163
  fresh &&
165
164
  'animate-in duration-200 fade-in slide-in-from-top-1 motion-reduce:animate-none',
@@ -168,73 +167,69 @@ function ItemCard({
168
167
  <span
169
168
  aria-hidden='true'
170
169
  className={cn(
171
- 'absolute top-3 bottom-3 left-0 w-0.5 rounded-full',
170
+ 'absolute top-3 bottom-3 -left-px w-0.5 rounded-full',
172
171
  selected
173
172
  ? 'bg-primary'
174
173
  : waiting
175
174
  ? 'bg-amber-500'
176
- : 'bg-transparent',
175
+ : unread
176
+ ? 'bg-primary/70'
177
+ : 'bg-transparent',
177
178
  )}
178
179
  />
179
- <span className='mt-0.5 flex shrink-0'>
180
- <InboxTypeIcon entry={entry} renderer={renderer} />
181
- </span>
182
180
  <Button
183
181
  variant='ghost'
184
182
  type='button'
185
183
  aria-current={selected ? 'true' : undefined}
186
184
  // Stretched over the whole card, so a click anywhere on it selects the item.
187
- className="block h-auto min-w-0 flex-1 cursor-pointer space-y-1 rounded-none border-0 bg-transparent p-0 text-left font-normal whitespace-normal hover:bg-transparent active:translate-y-0 after:absolute after:inset-0 after:rounded-lg after:content-[''] focus-visible:outline-none"
185
+ className="grid h-auto min-w-0 flex-1 cursor-pointer grid-cols-[minmax(0,1fr)] gap-1 rounded-none border-0 bg-transparent p-0 text-left font-normal whitespace-normal hover:bg-transparent active:translate-y-0 after:absolute after:inset-0 after:rounded-lg after:content-[''] focus-visible:outline-none"
188
186
  onClick={() => onSelect(entry)}
189
187
  onKeyDown={onKeyDown}
190
188
  >
191
- <span className='flex items-center gap-1.5 text-xs text-muted-foreground'>
192
- {notice?.subject?.label ? (
193
- <span className='shrink-0 font-mono'>{notice.subject.label}</span>
194
- ) : null}
195
- <span className='truncate'>{wording.label(entry)}</span>
189
+ {/* The title, bold while unread, with the time at the right; the menu button takes the time's place while
190
+ the card is hovered or its menu open. */}
191
+ <span className='flex min-w-0 items-baseline gap-1.5'>
192
+ <span
193
+ className={cn(
194
+ 'min-w-0 truncate text-sm group-has-focus-visible:underline',
195
+ unread ? 'font-semibold' : 'font-medium',
196
+ )}
197
+ >
198
+ {title}
199
+ {unread ? (
200
+ <span className='sr-only'>
201
+ {t('inbox.unread', { defaultValue: 'Unread' })}
202
+ </span>
203
+ ) : null}
204
+ </span>
196
205
  {notice && notice.count > 1 ? (
197
- <span className='shrink-0 tabular-nums'>
206
+ <span className='shrink-0 text-xs text-muted-foreground tabular-nums'>
198
207
  {t('inbox.count', {
199
208
  count: notice.count,
200
209
  defaultValue: '×{{count}}',
201
210
  })}
202
211
  </span>
203
212
  ) : null}
204
- {settled ? (
205
- <span className='shrink-0'>
206
- {t('inbox.resolved', { defaultValue: 'Handled' })}
207
- </span>
208
- ) : null}
209
213
  {/* The latest notice's time: a merged item (×N) is sent anew, replacing the previous one. */}
210
214
  <time
211
- className='ml-auto shrink-0 tabular-nums'
215
+ className='ml-auto shrink-0 text-xs text-muted-foreground tabular-nums group-has-focus-visible:invisible group-hover:invisible group-has-data-popup-open:invisible'
212
216
  dateTime={item.createdAt}
213
217
  title={new Date(item.createdAt).toLocaleString(language)}
214
218
  >
215
219
  {relativeTime(item.createdAt, language)}
216
220
  </time>
217
221
  </span>
218
- <span
219
- className={cn(
220
- 'flex items-center gap-1.5 text-sm group-focus-within:underline',
221
- unread ? 'font-semibold' : 'font-normal',
222
- )}
223
- >
224
- {unread ? (
225
- <span className='size-1.5 shrink-0 rounded-full bg-primary'>
226
- <span className='sr-only'>
227
- {t('inbox.unread', { defaultValue: 'Unread' })}
228
- </span>
229
- </span>
222
+ {/* Then one line of particulars: whether it was handled, its kind and what it is about, and its sentence. */}
223
+ <span className='line-clamp-1 text-xs text-muted-foreground wrap-anywhere'>
224
+ {settled
225
+ ? `${t('inbox.resolved', { defaultValue: 'Handled' })} · `
226
+ : null}
227
+ {wording.label(entry)}
228
+ {notice?.subject?.label ? (
229
+ <span className='font-mono'> {notice.subject.label}</span>
230
230
  ) : null}
231
- <span className='truncate'>{title}</span>
231
+ {sentence ? ` · ${sentence}` : null}
232
232
  </span>
233
- {sentence ? (
234
- <span className='line-clamp-1 text-sm text-muted-foreground wrap-anywhere'>
235
- {sentence}
236
- </span>
237
- ) : null}
238
233
  </Button>
239
234
  <DropdownMenu>
240
235
  <DropdownMenuTrigger
@@ -243,7 +238,7 @@ function ItemCard({
243
238
  variant='ghost'
244
239
  size='icon-xs'
245
240
  disabled={busy}
246
- className='relative opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-popup-open:opacity-100'
241
+ className='absolute top-2 right-2 opacity-0 group-hover:opacity-100 group-has-focus-visible:opacity-100 focus-visible:opacity-100 data-popup-open:opacity-100'
247
242
  aria-label={t('inbox.actionsFor', {
248
243
  title,
249
244
  defaultValue: 'Actions for {{title}}',
@@ -257,15 +252,6 @@ function ItemCard({
257
252
  <DropdownMenuGroup>{menuItems(DropdownMenuItem)}</DropdownMenuGroup>
258
253
  </DropdownMenuContent>
259
254
  </DropdownMenu>
260
- <ChevronRightIcon
261
- aria-hidden='true'
262
- className={cn(
263
- 'size-4 shrink-0 self-center transition-colors',
264
- selected
265
- ? 'text-foreground'
266
- : 'text-muted-foreground group-hover:text-foreground',
267
- )}
268
- />
269
255
  </ContextMenuTrigger>
270
256
  <ContextMenuContent>
271
257
  <ContextMenuGroup>{menuItems(ContextMenuItem)}</ContextMenuGroup>
@@ -1,4 +1,4 @@
1
- import { AlertCircleIcon, InboxIcon } from 'lucide-react';
1
+ import { AlertCircleIcon, InboxIcon, ListTodoIcon } from 'lucide-react';
2
2
  import type { ReactElement, ReactNode } from 'react';
3
3
 
4
4
  import {
@@ -7,7 +7,6 @@ import {
7
7
  AlertDescription,
8
8
  AlertTitle,
9
9
  } from '#components/ui/alert';
10
- import { Badge } from '#components/ui/badge';
11
10
  import { Button } from '#components/ui/button';
12
11
  import {
13
12
  Empty,
@@ -28,7 +27,6 @@ import {
28
27
  import { Skeleton } from '#components/ui/skeleton';
29
28
  import { Spinner } from '#components/ui/spinner';
30
29
  import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
31
- import { cn } from 'cn';
32
30
 
33
31
  import { useInboxFrameText } from './frame.js';
34
32
  import type { InboxAction } from './inbox-actions.js';
@@ -261,7 +259,7 @@ export function InboxList({
261
259
  <section aria-labelledby={heading} className='space-y-1'>
262
260
  <h2
263
261
  id={heading}
264
- className='flex items-center gap-2 px-3 text-xs font-medium tracking-wider text-muted-foreground uppercase'
262
+ className='flex items-center gap-2 text-xs font-medium tracking-wider text-muted-foreground uppercase'
265
263
  >
266
264
  {category.title(t)}
267
265
  {/* What still waits, as To do counts it; handled items stay listed in All but are not counted. */}
@@ -311,80 +309,80 @@ export function InboxList({
311
309
 
312
310
  return (
313
311
  <>
314
- <div className='shrink-0 space-y-2 border-b px-3 py-2.5'>
315
- <Tabs
316
- value={view}
317
- onValueChange={(value) => {
318
- const next = value as InboxView;
319
- onFilter({
320
- view: next,
321
- kind: kindsOf(next, categories).includes(kind) ? kind : 'all',
322
- });
312
+ {/* The views as line tabs (client/styles.css draws them as a record page's), each led by its icon, a count in
313
+ muted figures. */}
314
+ <Tabs
315
+ value={view}
316
+ onValueChange={(value) => {
317
+ const next = value as InboxView;
318
+ onFilter({
319
+ view: next,
320
+ kind: kindsOf(next, categories).includes(kind) ? kind : 'all',
321
+ });
322
+ }}
323
+ >
324
+ <TabsList
325
+ variant='line'
326
+ className='shrink-0 [--tabs-inset:--spacing(6)]'
327
+ >
328
+ {INBOX_VIEWS.map((value) => {
329
+ const count = value === 'todo' ? pending : unread;
330
+ const Icon = value === 'todo' ? ListTodoIcon : InboxIcon;
331
+ return (
332
+ <TabsTrigger key={value} value={value}>
333
+ <Icon aria-hidden='true' />
334
+ {value === 'todo'
335
+ ? t('inbox.views.todo', { defaultValue: 'To do' })
336
+ : t('inbox.views.all', { defaultValue: 'All' })}
337
+ {count > 0 ? (
338
+ <span
339
+ className='text-xs text-muted-foreground tabular-nums'
340
+ aria-label={
341
+ value === 'todo'
342
+ ? t('inbox.pendingDecisions', {
343
+ count,
344
+ defaultValue: '{{count}} waiting',
345
+ })
346
+ : t('inbox.unreadCount', {
347
+ count,
348
+ defaultValue: '{{count}} unread',
349
+ })
350
+ }
351
+ >
352
+ {count}
353
+ </span>
354
+ ) : null}
355
+ </TabsTrigger>
356
+ );
357
+ })}
358
+ </TabsList>
359
+ </Tabs>
360
+ <div className='flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-6 py-2.5'>
361
+ <Select
362
+ items={kinds}
363
+ value={kind}
364
+ onValueChange={(next: string | null) => {
365
+ if (next) onFilter({ view, kind: next });
323
366
  }}
324
367
  >
325
- <TabsList variant='line' className='w-full'>
326
- {INBOX_VIEWS.map((value) => {
327
- const count = value === 'todo' ? pending : unread;
328
- return (
329
- <TabsTrigger key={value} value={value}>
330
- {value === 'todo'
331
- ? t('inbox.views.todo', { defaultValue: 'To do' })
332
- : t('inbox.views.all', { defaultValue: 'All' })}
333
- {count > 0 ? (
334
- <Badge
335
- variant={value === 'todo' ? 'outline' : 'secondary'}
336
- className={cn(
337
- 'h-4 px-1.5 tabular-nums',
338
- value === 'todo' &&
339
- 'border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300',
340
- )}
341
- aria-label={
342
- value === 'todo'
343
- ? t('inbox.pendingDecisions', {
344
- count,
345
- defaultValue: '{{count}} waiting',
346
- })
347
- : t('inbox.unreadCount', {
348
- count,
349
- defaultValue: '{{count}} unread',
350
- })
351
- }
352
- >
353
- {count}
354
- </Badge>
355
- ) : null}
356
- </TabsTrigger>
357
- );
358
- })}
359
- </TabsList>
360
- </Tabs>
361
- <div className='flex flex-wrap items-center justify-between gap-2'>
362
- <Select
363
- items={kinds}
364
- value={kind}
365
- onValueChange={(next: string | null) => {
366
- if (next) onFilter({ view, kind: next });
367
- }}
368
+ <SelectTrigger
369
+ size='sm'
370
+ className='w-40'
371
+ aria-label={t('inbox.kinds.label', { defaultValue: 'Type' })}
368
372
  >
369
- <SelectTrigger
370
- size='sm'
371
- className='w-40'
372
- aria-label={t('inbox.kinds.label', { defaultValue: 'Type' })}
373
- >
374
- <SelectValue />
375
- </SelectTrigger>
376
- <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
377
- {kinds.map((item) => (
378
- <SelectItem key={item.value} value={item.value}>
379
- {item.label}
380
- </SelectItem>
381
- ))}
382
- </SelectContent>
383
- </Select>
384
- {footer}
385
- </div>
373
+ <SelectValue />
374
+ </SelectTrigger>
375
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
376
+ {kinds.map((item) => (
377
+ <SelectItem key={item.value} value={item.value}>
378
+ {item.label}
379
+ </SelectItem>
380
+ ))}
381
+ </SelectContent>
382
+ </Select>
383
+ {footer}
386
384
  </div>
387
- <div className='min-h-0 flex-1 overflow-y-auto px-2 py-3'>{content}</div>
385
+ <div className='min-h-0 flex-1 overflow-y-auto px-6 py-3'>{content}</div>
388
386
  <p className='hidden shrink-0 items-center gap-3 border-t px-4 py-2 text-xs text-muted-foreground lg:flex'>
389
387
  <span className='inline-flex items-center gap-1'>
390
388
  <Kbd>J</Kbd>
@@ -410,7 +408,7 @@ function ListEmpty({
410
408
  readonly action?: ReactNode;
411
409
  }): ReactElement {
412
410
  return (
413
- <Empty className='mx-1 min-h-48'>
411
+ <Empty className='min-h-48'>
414
412
  <EmptyHeader>
415
413
  <EmptyMedia variant='icon'>
416
414
  <InboxIcon />
package/client/index.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { resolveAppRuntime } from '@nocobase/app-client/runtime';
8
8
  import { createRoot } from 'react-dom/client';
9
9
 
10
+ import { revealScrollbarsWhileScrolling } from './lib/scrollbar';
10
11
  import appRuntime from './runtime';
11
12
  import { AppStartupError } from './startup';
12
13
  import './styles.css';
@@ -19,6 +20,9 @@ if (!container) {
19
20
  throw new Error('Missing application root element.');
20
21
  }
21
22
 
23
+ // Scrollbars show only while their element scrolls (client/styles.css).
24
+ revealScrollbarsWhileScrolling();
25
+
22
26
  const root = createRoot(container);
23
27
  let app: ClientApplication | undefined;
24
28
  let applicationStarted = false;
@@ -48,11 +48,11 @@ export function AppSidebarProvider({
48
48
  open={!collapsed}
49
49
  onOpenChange={(open) => setCollapsed(!open)}
50
50
  className='h-svh bg-background'
51
- // Give expanded navigation more breathing room while retaining density-aware sizing
52
- // and the existing compact icon rail (the provider's documented width override).
51
+ // shadcn's 16rem in spacing units, so a density preset that changes the unit scales the sidebar together with
52
+ // the items inside it (the provider's documented width override).
53
53
  style={
54
54
  {
55
- '--sidebar-width': 'calc(var(--spacing) * 75)',
55
+ '--sidebar-width': 'calc(var(--spacing) * 64)',
56
56
  '--sidebar-width-icon': 'calc(var(--spacing) * 12)',
57
57
  } as CSSProperties
58
58
  }
@@ -102,7 +102,7 @@ export function AppSidebar({
102
102
  }, [isMobile, openMobile, setOpenMobile]);
103
103
 
104
104
  const header = (
105
- <SidebarHeader className='h-16 shrink-0 flex-row items-center justify-between border-b border-sidebar-border/70 px-5 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0'>
105
+ <SidebarHeader className='h-16 shrink-0 flex-row items-center justify-between border-b border-sidebar-border px-5 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0'>
106
106
  <AppBrand compact={state === 'collapsed' && !isMobile} />
107
107
  {isMobile ? (
108
108
  <Button
@@ -123,7 +123,7 @@ export function AppSidebar({
123
123
  <>
124
124
  {header}
125
125
  {/* The primitive hides the icon mode's overflow; a long menu still scrolls there. */}
126
- <SidebarContent className='app-navigation group-data-[collapsible=icon]:overflow-y-auto [&_[data-slot=sidebar-menu-button]]:h-10 [&_[data-slot=sidebar-menu-button]]:px-3'>
126
+ <SidebarContent className='app-navigation group-data-[collapsible=icon]:overflow-y-auto'>
127
127
  {children}
128
128
  </SidebarContent>
129
129
  {footer}
@@ -8,7 +8,7 @@ export function LayoutHeader({
8
8
  return (
9
9
  <header
10
10
  className={cn(
11
- 'flex h-16 shrink-0 items-center border-b border-border/70 bg-background/85 px-3 backdrop-blur-xl md:px-4',
11
+ 'flex h-16 shrink-0 items-center border-b border-border bg-background/85 px-3 backdrop-blur-xl md:px-4',
12
12
  className,
13
13
  )}
14
14
  {...props}