@nocobase/app-template-default 1.0.0-beta.58 → 1.0.0-beta.60

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 (72) hide show
  1. package/AGENTS.md +28 -25
  2. package/README.MD +7 -57
  3. package/client/agent-annotations-host.ts +8 -2
  4. package/client/app.ts +0 -2
  5. package/client/components/brand-loading-mark.tsx +46 -0
  6. package/client/components/brand-mark-geometry.ts +7 -0
  7. package/client/components/brand-spinner.tsx +22 -0
  8. package/client/components/brand-watermark.tsx +32 -0
  9. package/client/components/loading.tsx +4 -3
  10. package/client/components/route-overlay.tsx +15 -4
  11. package/client/components/status-badge.tsx +29 -0
  12. package/client/components/ui/alert-dialog.tsx +186 -0
  13. package/client/components/ui/card.tsx +102 -0
  14. package/client/components/ui/dialog.tsx +2 -2
  15. package/client/components/ui/radio-group.tsx +35 -0
  16. package/client/components/ui/spinner.tsx +2 -20
  17. package/client/components/ui/toast.tsx +3 -2
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +3 -3
  19. package/client/extensions/nocobase-inbox/inbox-item.tsx +4 -3
  20. package/client/layouts/app-layout.tsx +8 -28
  21. package/client/layouts/components/app-sidebar.tsx +4 -4
  22. package/client/layouts/components/header-actions.tsx +3 -36
  23. package/client/layouts/index.ts +0 -1
  24. package/client/lib/toaster.ts +1 -1
  25. package/client/locales/en-US.ts +8 -164
  26. package/client/locales/system/en-US.ts +344 -0
  27. package/client/locales/system/zh-CN.ts +320 -0
  28. package/client/locales/zh-CN.ts +3 -155
  29. package/client/pages/auth/login.tsx +1 -0
  30. package/client/pages/auth/shared.tsx +9 -9
  31. package/client/pages/home/capabilities.ts +52 -0
  32. package/client/pages/home/index.tsx +190 -0
  33. package/client/pages/home/prompt-dialog.tsx +122 -0
  34. package/client/pages/home/use-copy.ts +38 -0
  35. package/client/plugins.ts +2 -2
  36. package/client/routes.ts +5 -11
  37. package/client/routing/app-router.tsx +5 -55
  38. package/client/routing/route-navigation.ts +1 -1
  39. package/client/styles.css +244 -0
  40. package/client/theme/theme-settings.tsx +22 -20
  41. package/client/theme/themes/compact.css +34 -16
  42. package/client/theme/themes/default.css +34 -16
  43. package/eslint.config.js +0 -13
  44. package/index.html +48 -16
  45. package/package.json +23 -27
  46. package/tests/components/app-layout-routes.test.tsx +1 -1
  47. package/tests/components/home.test.tsx +174 -0
  48. package/tests/components/inbox.test.tsx +0 -19
  49. package/tests/components/layout-i18n.test.tsx +5 -92
  50. package/tests/components/primitive-labels.test.tsx +61 -1
  51. package/tests/fixtures/theme-tokens.html +1 -1
  52. package/tests/fixtures/theme-tokens.tsx +7 -0
  53. package/tests/logic/app-server.test.ts +0 -15
  54. package/tests/logic/client-plugin-registry.test.ts +0 -21
  55. package/tests/logic/client-routes.test.ts +2 -6
  56. package/tests/logic/client-shell.test.tsx +11 -107
  57. package/tests/logic/client-theme.test.tsx +17 -1
  58. package/tests/logic/config.test.ts +0 -1
  59. package/tests/logic/plugin-commands.test.ts +1 -1
  60. package/tsconfig.json +1 -4
  61. package/tsconfig.server.json +1 -6
  62. package/vitest.config.ts +1 -8
  63. package/client/components/inbox-badge.ts +0 -49
  64. package/client/components/inbox-button.tsx +0 -132
  65. package/client/components/inbox-header-button.tsx +0 -25
  66. package/client/layouts/components/surface-empty.tsx +0 -35
  67. package/client/layouts/settings-layout.tsx +0 -172
  68. package/client/pages/home.tsx +0 -16
  69. package/server/config/workflow.ts +0 -24
  70. package/tests/logic/client-settings.test.tsx +0 -841
  71. package/tests/logic/dev-routes-production-build.test.ts +0 -168
  72. package/tests/logic/plugin-page-authz.test.ts +0 -53
@@ -0,0 +1,190 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import {
3
+ ArrowRightIcon,
4
+ CheckIcon,
5
+ CopyIcon,
6
+ MessageSquareTextIcon,
7
+ SparklesIcon,
8
+ } from 'lucide-react';
9
+ import { type ReactElement, useState } from 'react';
10
+
11
+ import { PageContainer } from '#components/page-container';
12
+ import { Button } from '#components/ui/button';
13
+ import {
14
+ Card,
15
+ CardDescription,
16
+ CardFooter,
17
+ CardHeader,
18
+ CardTitle,
19
+ } from '#components/ui/card';
20
+
21
+ import { CAPABILITIES, type Capability } from './capabilities.js';
22
+ import { PromptDialog } from './prompt-dialog.js';
23
+ import { useCopy } from './use-copy.js';
24
+
25
+ const STEPS = ['describe', 'build', 'review'] as const;
26
+
27
+ export default function HomePage(): ReactElement {
28
+ const { t } = useTranslation();
29
+ const [selected, setSelected] = useState<Capability>();
30
+ const [open, setOpen] = useState(false);
31
+ // Changes on every opening, so the dialog remounts and drops the edits of the previous one.
32
+ const [openCount, setOpenCount] = useState(0);
33
+
34
+ const openCapability = (capability: Capability): void => {
35
+ setSelected(capability);
36
+ setOpenCount((count) => count + 1);
37
+ setOpen(true);
38
+ };
39
+
40
+ return (
41
+ <PageContainer className='mx-auto max-w-6xl'>
42
+ <AgentIntro />
43
+ <section
44
+ aria-labelledby='home-capabilities'
45
+ className='flex flex-col gap-4'
46
+ >
47
+ <div className='flex flex-col gap-1'>
48
+ <h2 id='home-capabilities' className='text-base font-medium'>
49
+ {t('home.capabilitiesTitle')}
50
+ </h2>
51
+ <p className='text-sm text-muted-foreground'>
52
+ {t('home.capabilitiesDescription')}
53
+ </p>
54
+ </div>
55
+ <ul className='grid gap-4 sm:grid-cols-2 xl:grid-cols-3'>
56
+ {CAPABILITIES.map((capability) => (
57
+ <li key={capability.id} className='flex'>
58
+ <CapabilityCard
59
+ capability={capability}
60
+ onOpen={() => openCapability(capability)}
61
+ />
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </section>
66
+ {selected ? (
67
+ <PromptDialog
68
+ key={openCount}
69
+ capability={selected}
70
+ open={open}
71
+ onOpenChange={setOpen}
72
+ />
73
+ ) : null}
74
+ </PageContainer>
75
+ );
76
+ }
77
+
78
+ /** The first part of the page: development here happens by asking a coding agent, which the steps and sample show. */
79
+ function AgentIntro(): ReactElement {
80
+ const { t } = useTranslation();
81
+ const { copied, copy } = useCopy();
82
+ const example = t('home.example.text');
83
+
84
+ return (
85
+ <section className='grid gap-8 rounded-2xl border border-border bg-linear-to-br from-primary/5 via-card to-card p-6 md:p-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center'>
86
+ <div className='flex flex-col gap-6'>
87
+ <div className='flex flex-col gap-4'>
88
+ <p className='flex items-center gap-2 text-sm font-medium text-primary'>
89
+ <SparklesIcon className='size-4' aria-hidden />
90
+ {t('home.platform')}
91
+ </p>
92
+ <h1 className='font-heading text-3xl font-semibold tracking-[-0.035em] text-balance md:text-4xl'>
93
+ {t('home.title')}
94
+ </h1>
95
+ <p className='text-base leading-7 text-muted-foreground'>
96
+ {t('home.description')}
97
+ </p>
98
+ </div>
99
+ <ol className='flex flex-col gap-3'>
100
+ {STEPS.map((step, index) => (
101
+ <li key={step} className='flex gap-3'>
102
+ <span className='flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary'>
103
+ {index + 1}
104
+ </span>
105
+ <div className='flex flex-col gap-0.5'>
106
+ <span className='text-sm font-medium'>
107
+ {t(`home.steps.${step}.title`)}
108
+ </span>
109
+ <span className='text-sm text-muted-foreground'>
110
+ {t(`home.steps.${step}.description`)}
111
+ </span>
112
+ </div>
113
+ </li>
114
+ ))}
115
+ </ol>
116
+ </div>
117
+ <figure className='flex flex-col overflow-hidden rounded-xl border border-primary/10 bg-card shadow-sm'>
118
+ <figcaption className='flex items-center justify-between gap-2 border-b border-primary/10 px-4 py-2.5'>
119
+ <span className='flex items-center gap-2 text-sm font-medium'>
120
+ <MessageSquareTextIcon
121
+ className='size-4 text-primary'
122
+ aria-hidden
123
+ />
124
+ {t('home.example.title')}
125
+ </span>
126
+ <Button variant='ghost' size='sm' onClick={() => void copy(example)}>
127
+ {copied ? (
128
+ <CheckIcon data-icon='inline-start' />
129
+ ) : (
130
+ <CopyIcon data-icon='inline-start' />
131
+ )}
132
+ {copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
133
+ </Button>
134
+ </figcaption>
135
+ <p className='bg-primary/5 px-4 py-4 text-sm leading-7 whitespace-pre-line'>
136
+ {example}
137
+ </p>
138
+ <p className='border-t border-primary/10 px-4 py-2.5 text-xs text-muted-foreground'>
139
+ {t('home.example.hint')}
140
+ </p>
141
+ </figure>
142
+ </section>
143
+ );
144
+ }
145
+
146
+ function CapabilityCard({
147
+ capability,
148
+ onOpen,
149
+ }: {
150
+ readonly capability: Capability;
151
+ readonly onOpen: () => void;
152
+ }): ReactElement {
153
+ const { t } = useTranslation();
154
+ const prefix = `home.capabilities.${capability.id}`;
155
+ const Icon = capability.icon;
156
+
157
+ return (
158
+ <button
159
+ type='button'
160
+ onClick={onOpen}
161
+ className='group flex w-full cursor-pointer rounded-xl text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50'
162
+ >
163
+ <Card className='w-full transition-[translate,box-shadow] duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md group-hover:ring-foreground/20 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0'>
164
+ <CardHeader className='gap-3'>
165
+ {/* The chip only deepens its tint on hover rather than inverting: swapping a light background and a dark icon
166
+ passes both through the same color halfway, where the icon vanishes for a moment and reads as a flicker. */}
167
+ <span className='flex size-9 items-center justify-center rounded-lg bg-primary/5 text-primary ring-1 ring-transparent transition-[background-color,box-shadow] duration-200 ease-out group-hover:bg-primary/10 group-hover:ring-primary/20 motion-reduce:transition-none'>
168
+ <Icon
169
+ className='size-4 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none'
170
+ aria-hidden
171
+ />
172
+ </span>
173
+ <div className='flex flex-col gap-1'>
174
+ <CardTitle>{t(`${prefix}.title`)}</CardTitle>
175
+ <CardDescription className='leading-6'>
176
+ {t(`${prefix}.description`)}
177
+ </CardDescription>
178
+ </div>
179
+ </CardHeader>
180
+ <CardFooter className='mt-auto justify-between text-sm text-muted-foreground transition-colors duration-200 ease-out group-hover:text-foreground'>
181
+ {t('home.viewPrompts', { count: capability.prompts.length })}
182
+ <ArrowRightIcon
183
+ className='size-4 transition-transform duration-200 ease-out group-hover:translate-x-1 motion-reduce:transition-none'
184
+ aria-hidden
185
+ />
186
+ </CardFooter>
187
+ </Card>
188
+ </button>
189
+ );
190
+ }
@@ -0,0 +1,122 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { CheckIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
3
+ import { type ReactElement, useState } from 'react';
4
+
5
+ import { Button } from '#components/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogClose,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '#components/ui/dialog';
15
+ import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
16
+ import { Textarea } from '#components/ui/textarea';
17
+
18
+ import type { Capability } from './capabilities.js';
19
+ import { useCopy } from './use-copy.js';
20
+
21
+ export interface PromptDialogProps {
22
+ readonly capability: Capability;
23
+ readonly open: boolean;
24
+ readonly onOpenChange: (open: boolean) => void;
25
+ }
26
+
27
+ /**
28
+ * The prompts of one capability, ready to hand to a coding agent. Each prompt can be edited before copying. Edits are
29
+ * not saved: the page mounts a new dialog each time one is opened, so it starts from the default wording again.
30
+ */
31
+ export function PromptDialog({
32
+ capability,
33
+ open,
34
+ onOpenChange,
35
+ }: PromptDialogProps): ReactElement {
36
+ const { t } = useTranslation();
37
+ const { copied, copy } = useCopy();
38
+ const [current, setCurrent] = useState(capability.prompts[0]);
39
+ // Edited text per prompt id; a prompt without an entry shows its default wording.
40
+ const [edits, setEdits] = useState<Readonly<Record<string, string>>>({});
41
+
42
+ const prefix = `home.capabilities.${capability.id}`;
43
+ const defaultText = t(`${prefix}.prompts.${current}.text`);
44
+ const text = edits[current] ?? defaultText;
45
+ const Icon = capability.icon;
46
+
47
+ return (
48
+ <Dialog open={open} onOpenChange={onOpenChange}>
49
+ <DialogContent className='gap-5 sm:max-w-2xl'>
50
+ <DialogHeader className='flex-row items-center gap-3 pr-8'>
51
+ <span className='flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/5 text-primary'>
52
+ <Icon className='size-5' aria-hidden />
53
+ </span>
54
+ <div className='flex min-w-0 flex-col gap-1.5'>
55
+ <DialogTitle>{t(`${prefix}.title`)}</DialogTitle>
56
+ <DialogDescription>{t(`${prefix}.description`)}</DialogDescription>
57
+ </div>
58
+ </DialogHeader>
59
+ <div className='flex flex-col gap-3'>
60
+ {capability.prompts.length > 1 ? (
61
+ <Tabs
62
+ value={current}
63
+ onValueChange={(value) => {
64
+ if (typeof value === 'string') setCurrent(value);
65
+ }}
66
+ >
67
+ <TabsList className='w-full'>
68
+ {capability.prompts.map((id) => (
69
+ <TabsTrigger key={id} value={id}>
70
+ {t(`${prefix}.prompts.${id}.label`)}
71
+ </TabsTrigger>
72
+ ))}
73
+ </TabsList>
74
+ </Tabs>
75
+ ) : null}
76
+ <Textarea
77
+ aria-label={t('home.prompt.label')}
78
+ className='field-sizing-fixed h-60 max-h-[50dvh] resize-none leading-6'
79
+ value={text}
80
+ onChange={(event) =>
81
+ setEdits((previous) => ({
82
+ ...previous,
83
+ [current]: event.target.value,
84
+ }))
85
+ }
86
+ />
87
+ <p className='text-xs text-muted-foreground'>
88
+ {t('home.prompt.hint')}
89
+ </p>
90
+ </div>
91
+ <DialogFooter className='sm:items-center'>
92
+ <Button
93
+ variant='ghost'
94
+ className='sm:mr-auto'
95
+ disabled={text === defaultText}
96
+ onClick={() =>
97
+ setEdits((previous) => {
98
+ const rest = { ...previous };
99
+ delete rest[current];
100
+ return rest;
101
+ })
102
+ }
103
+ >
104
+ <RotateCcwIcon data-icon='inline-start' />
105
+ {t('home.prompt.reset')}
106
+ </Button>
107
+ <DialogClose render={<Button variant='outline' />}>
108
+ {t('actions.close')}
109
+ </DialogClose>
110
+ <Button disabled={text.trim() === ''} onClick={() => void copy(text)}>
111
+ {copied ? (
112
+ <CheckIcon data-icon='inline-start' />
113
+ ) : (
114
+ <CopyIcon data-icon='inline-start' />
115
+ )}
116
+ {copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
117
+ </Button>
118
+ </DialogFooter>
119
+ </DialogContent>
120
+ </Dialog>
121
+ );
122
+ }
@@ -0,0 +1,38 @@
1
+ import { useToaster } from '@nocobase/app-client';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import { useCallback, useEffect, useState } from 'react';
4
+
5
+ export interface CopyState {
6
+ /** Whether the last copy succeeded within the past two seconds, for a button to show a check mark. */
7
+ readonly copied: boolean;
8
+ readonly copy: (text: string) => Promise<void>;
9
+ }
10
+
11
+ /** Copies text to the clipboard and reports the outcome in a toast. */
12
+ export function useCopy(): CopyState {
13
+ const { t } = useTranslation();
14
+ const toaster = useToaster();
15
+ const [copied, setCopied] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (!copied) return;
19
+ const timer = setTimeout(() => setCopied(false), 2000);
20
+ return () => clearTimeout(timer);
21
+ }, [copied]);
22
+
23
+ const copy = useCallback(
24
+ async (text: string): Promise<void> => {
25
+ try {
26
+ await navigator.clipboard.writeText(text);
27
+ setCopied(true);
28
+ toaster.show({ type: 'success', title: t('home.prompt.copied') });
29
+ } catch {
30
+ // The Clipboard API is missing outside a secure context and rejects without permission.
31
+ toaster.show({ type: 'error', title: t('home.prompt.copyFailed') });
32
+ }
33
+ },
34
+ [t, toaster],
35
+ );
36
+
37
+ return { copied, copy };
38
+ }
package/client/plugins.ts CHANGED
@@ -25,8 +25,8 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
25
25
  sharingRules(),
26
26
  restrictionRules(),
27
27
  databaseExplorer(),
28
- users({ mount: 'settings', path: '/users' }),
29
- apiKeys({ path: '/api-keys' }),
28
+ users(),
29
+ apiKeys(),
30
30
  i18n(),
31
31
  notificationInApp(),
32
32
  notification(),
package/client/routes.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Home } from 'lucide-react';
2
2
  import {
3
3
  defineAppRoutes,
4
- defineSettingsRoutes,
5
4
  type AppClientRouteContribution,
6
5
  } from '@nocobase/app-client/plugins';
7
6
 
@@ -11,15 +10,15 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
11
10
  // no permission change can leave a user signed in with nowhere to land.
12
11
  authz: 'skip',
13
12
  auth: 'required',
14
- componentLoader: () => import('./pages/home.js'),
13
+ componentLoader: () => import('./pages/home/index.js'),
15
14
  name: 'home',
16
15
  navigation: { title: 'navigation.home', icon: Home },
17
16
  path: '/',
18
17
  },
19
18
  {
20
- // The header's inbox button is where a user looks for messages, so this page is reached from there. Declaring no
21
- // navigation keeps a second menu entry from pointing at the one destination the button already owns. The plugin's
22
- // API answers each person's own messages only, so the page needs no authorization of its own.
19
+ // Declares no navigation, so the application decides where its entry goes (for example a sidebar item with an
20
+ // unread badge). The plugin's API answers each person's own messages only, so the page needs no authorization of
21
+ // its own.
23
22
  authz: 'skip',
24
23
  auth: 'required',
25
24
  componentLoader: () => import('./pages/inbox.js'),
@@ -65,11 +64,6 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
65
64
  },
66
65
  ]);
67
66
 
68
- const settingsRoutes: AppClientRouteContribution = defineSettingsRoutes([]);
69
-
70
- const routes: readonly AppClientRouteContribution[] = [
71
- appRoutes,
72
- settingsRoutes,
73
- ];
67
+ const routes: readonly AppClientRouteContribution[] = [appRoutes];
74
68
 
75
69
  export default routes;
@@ -1,52 +1,29 @@
1
- import { useTranslation } from '@nocobase/i18n/client';
2
1
  import {
3
2
  GuestAuthentication,
4
3
  RequiredAuthentication,
5
4
  } from '@nocobase/app-plugin-authentication/client';
6
5
  import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
7
- import { lazy, Suspense, useMemo, type ReactElement } from 'react';
6
+ import { useMemo, type ReactElement } from 'react';
8
7
  import { Navigate, Outlet, Route, Routes } from 'react-router';
9
8
 
10
- import { Loading } from '#components/loading';
11
9
  import { EMPTY_ARRAY } from '#lib/constants';
12
10
 
13
11
  import { AppLayout } from '../layouts/app-layout.js';
14
12
  import { renderRouteTree } from './route-tree.js';
15
13
  import { StandalonePageLayout } from './standalone-page-layout.js';
16
14
 
17
- // The settings centre brings its own chrome and navigation, none of which the application needs until someone opens
18
- // it. Loading it lazily keeps it out of the entry chunk, the same way every page it hosts stays out.
19
- const SettingsLayout = lazy(async () => ({
20
- default: (await import('../layouts/settings-layout.js')).SettingsLayout,
21
- }));
22
-
23
15
  export interface AppRouterProps {
24
- readonly settingsRouteTree: readonly AppClientRegisteredRoute[];
25
- readonly devRouteTree: readonly AppClientRegisteredRoute[];
26
16
  readonly clientRoutes: readonly AppClientRegisteredRoute[];
27
17
  }
28
18
 
29
- export function AppRouter(inputProps: AppRouterProps): ReactElement {
30
- const { t } = useTranslation();
31
- const { settingsRouteTree, devRouteTree, clientRoutes } = inputProps;
32
-
33
- const settingsRoutes = useMemo(
34
- () =>
35
- filterRouteTree(
36
- clientRoutes,
37
- (route) =>
38
- route.auth === 'required' && route.path.startsWith('/settings/'),
39
- ),
40
- [clientRoutes],
41
- );
19
+ export function AppRouter({ clientRoutes }: AppRouterProps): ReactElement {
42
20
  const routeGroups = useMemo(
43
21
  () => ({
44
22
  guest: clientRoutes.filter((route) => route.auth === 'guest'),
45
23
  optional: clientRoutes.filter((route) => route.auth === 'optional'),
46
24
  required: filterRouteTree(
47
25
  clientRoutes,
48
- (route) =>
49
- route.auth === 'required' && !route.path.startsWith('/settings/'),
26
+ (route) => route.auth === 'required',
50
27
  ),
51
28
  }),
52
29
  [clientRoutes],
@@ -61,36 +38,9 @@ export function AppRouter(inputProps: AppRouterProps): ReactElement {
61
38
  </RequiredAuthentication>
62
39
  }
63
40
  >
64
- <Route
65
- element={
66
- <AppLayout routes={routeGroups.required} devRoutes={devRouteTree} />
67
- }
68
- >
41
+ <Route element={<AppLayout routes={routeGroups.required} />}>
69
42
  {renderRouteTree(routeGroups.required)}
70
- {/* Pages plugins declare with `defineDevRoutes()` keep their `/dev/...` paths inside the application shell.
71
- A production build resolves no dev routes, so nothing is rendered here. */}
72
- {renderRouteTree(devRouteTree)}
73
43
  </Route>
74
- <Route
75
- path='/settings/*'
76
- element={
77
- <Suspense
78
- fallback={
79
- <Loading
80
- className='min-h-svh'
81
- label={t('status.loadingSettings', {
82
- defaultValue: 'Loading settings',
83
- })}
84
- />
85
- }
86
- >
87
- <SettingsLayout
88
- routeTree={settingsRouteTree}
89
- routes={settingsRoutes}
90
- />
91
- </Suspense>
92
- }
93
- />
94
44
  </Route>
95
45
 
96
46
  <Route
@@ -114,7 +64,7 @@ export function AppRouter(inputProps: AppRouterProps): ReactElement {
114
64
  );
115
65
  }
116
66
 
117
- /** Pure groups can span surfaces; a page and its descendants always share their shell. */
67
+ /** Pure groups can span shells; a page and its descendants always share their shell. */
118
68
  function filterRouteTree(
119
69
  routes: readonly AppClientRegisteredRoute[],
120
70
  predicate: (route: AppClientRegisteredRoute) => boolean,
@@ -58,7 +58,7 @@ export function selectedNavigationId(
58
58
  return selected ? routeKey(selected) : undefined;
59
59
  }
60
60
 
61
- export function navigationPages(
61
+ function navigationPages(
62
62
  items: readonly RouteNavigationItem[],
63
63
  ): AppClientRegisteredRoute[] {
64
64
  return items.flatMap(({ route, children }) => [