@checkstack/frontend 0.6.7 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # @checkstack/frontend
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
8
+
9
+ - Lazy plugin route pages: each plugin's route `element` references a `React.lazy`-wrapped page rendered inside a shared `<Suspense>` boundary. Plugins still register synchronously, so nav, slots, commands, API factories, and `foreignSignals` are available on first paint. This moves ~37 route-page chunks (~600 KB) out of the entry; the entry chunk drops from ~2.4 MB to ~190 KB. Auth flow pages stay eager. The `@checkstack/scripts` scaffold template generates lazy route pages too.
10
+ - Hardened contribution contract (BREAKING, frontend plugin contract): plugins declare contributions lazily and let the framework own code-splitting, Suspense, and per-plugin error isolation. Routes use `load: () => import("./Page").then((m) => ({ default: m.Page }))` instead of `element: <Page />` (`element` is still accepted for the rare page that must paint without a chunk fetch; provide exactly one). Slot extensions accept either an eager `component` or a lazy `load`; new `getLazyContribution` + `ExtensionComponent` exports from `@checkstack/frontend-api` render either kind. This also fixes runtime-installed plugins: `ExtensionSlot` subscribes to the plugin registry, and the API registry rebuilds when the plugin set changes (`getPlugins()` returns an immutable snapshot via `useSyncExternalStore`). A per-plugin error boundary contains a bad contribution.
11
+ - On-demand Monaco: the `@checkstack/ui` barrel no longer pulls the `@codingame/*` / `monaco-languageclient` stack into the initial load. `CodeEditor` lazy-loads its Monaco-backed editor behind `React.lazy` + Suspense, `validateTypeScriptSources` imports the editor API via in-body `await import(...)`, and the "vscode services ready" signal moved to a Monaco-free module. The ~10 MB editor body loads only when a `CodeEditor` mounts. A `react-vendor` `manualChunks` split was added for stable vendor caching.
12
+ - lucide-react 1.x + lighter icons/charts (BREAKING for icon consumers): lucide-react unified from three drifting ranges to `^1.17.0`. lucide v1 removed brand icons, so the GitHub/GitLab marks are vendored in `@checkstack/ui` (`GithubIcon`, `GitlabIcon`, `brandIcons`); a new `IconName` type (`LucideIconName | BrandIconName`) in `@checkstack/common` is canonical, accepted by `AuthStrategy.icon` and the card components, so data-driven brand names keep working. `DynamicIcon` no longer eagerly imports lucide's ~1600-icon map (~1 MB) - it lives in a `React.lazy` `iconRegistry` chunk fetched on first data-driven render, while statically named-imported icons tree-shake normally. The recharts-backed health-check charts (~300 KB) and the `HealthCheckSystemOverview` drawer leave the initial load.
13
+
14
+ BREAKING CHANGES:
15
+
16
+ - Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
17
+ - Any external consumer importing a brand icon from `lucide-react` (e.g. `import { Github } from "lucide-react"`) must switch to the vendored `@checkstack/ui` brand icons or a custom SVG.
18
+
19
+ This is a beta minor.
20
+
21
+ - 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
22
+
23
+ Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
24
+
25
+ The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
26
+
27
+ BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
28
+
29
+ This is a beta minor.
30
+
31
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
32
+
33
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
34
+
35
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
36
+
37
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
38
+
39
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [9dcc848]
44
+ - Updated dependencies [9dcc848]
45
+ - Updated dependencies [9dcc848]
46
+ - Updated dependencies [9dcc848]
47
+ - Updated dependencies [9dcc848]
48
+ - Updated dependencies [9dcc848]
49
+ - Updated dependencies [9dcc848]
50
+ - Updated dependencies [9dcc848]
51
+ - Updated dependencies [9dcc848]
52
+ - Updated dependencies [9dcc848]
53
+ - Updated dependencies [9dcc848]
54
+ - Updated dependencies [9dcc848]
55
+ - Updated dependencies [9dcc848]
56
+ - Updated dependencies [9dcc848]
57
+ - @checkstack/ui@1.13.0
58
+ - @checkstack/auth-frontend@0.7.0
59
+ - @checkstack/catalog-frontend@0.11.0
60
+ - @checkstack/common@0.13.0
61
+ - @checkstack/dependency-frontend@0.5.0
62
+ - @checkstack/frontend-api@0.7.0
63
+ - @checkstack/about-frontend@0.3.0
64
+ - @checkstack/announcement-frontend@0.4.0
65
+ - @checkstack/command-frontend@0.3.0
66
+ - @checkstack/signal-frontend@0.2.0
67
+ - @checkstack/signal-common@0.2.6
68
+
3
69
  ## 0.6.7
4
70
 
5
71
  ### Patch Changes
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@checkstack/frontend",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "frontend"
7
7
  },
8
8
  "type": "module",
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ "./tailwind-preset": "./tailwind-preset.js",
12
+ "./*": "./*"
13
+ },
9
14
  "scripts": {
10
15
  "dev": "vite",
11
16
  "build:vendor": "vite build --config vite.config.vendor.ts",
@@ -16,39 +21,39 @@
16
21
  "lint:code": "eslint . --max-warnings 0"
17
22
  },
18
23
  "dependencies": {
19
- "@checkstack/about-frontend": "0.2.22",
20
- "@checkstack/announcement-frontend": "0.3.6",
21
- "@checkstack/auth-frontend": "0.6.6",
22
- "@checkstack/catalog-frontend": "0.10.6",
23
- "@checkstack/command-frontend": "0.2.41",
24
+ "@checkstack/about-frontend": "0.2.23",
25
+ "@checkstack/announcement-frontend": "0.3.7",
26
+ "@checkstack/auth-frontend": "0.6.7",
27
+ "@checkstack/catalog-frontend": "0.10.7",
28
+ "@checkstack/command-frontend": "0.2.42",
24
29
  "@checkstack/common": "0.12.0",
25
- "@checkstack/dependency-frontend": "0.4.7",
30
+ "@checkstack/dependency-frontend": "0.4.8",
26
31
  "@checkstack/frontend-api": "0.6.0",
27
32
  "@checkstack/signal-common": "0.2.5",
28
33
  "@checkstack/signal-frontend": "0.1.5",
29
- "@checkstack/ui": "1.11.0",
30
- "@orpc/client": "^1.13.14",
31
- "@tanstack/react-query": "^5.64.0",
32
- "@tanstack/react-query-devtools": "^5.64.0",
33
- "better-auth": "^1.1.8",
34
+ "@checkstack/ui": "1.12.0",
35
+ "@orpc/client": "^1.14.4",
36
+ "@tanstack/react-query": "^5.100.14",
37
+ "@tanstack/react-query-devtools": "^5.100.14",
38
+ "autoprefixer": "^10.4.18",
39
+ "better-auth": "^1.6.13",
34
40
  "class-variance-authority": "^0.7.0",
35
41
  "clsx": "^2.1.0",
36
- "lucide-react": "^0.344.0",
37
- "react": "^18.2.0",
42
+ "lucide-react": "^1.17.0",
43
+ "react": "^18.3.1",
38
44
  "react-dom": "^18.2.0",
39
- "react-router-dom": "^6.22.0",
40
- "tailwind-merge": "^2.2.0"
45
+ "react-router-dom": "^7.16.0",
46
+ "tailwind-merge": "^2.2.0",
47
+ "tailwindcss": "^3.4.1",
48
+ "tailwindcss-animate": "^1.0.7"
41
49
  },
42
50
  "devDependencies": {
43
51
  "@checkstack/scripts": "0.3.4",
44
52
  "@checkstack/tsconfig": "0.0.7",
45
53
  "@types/react": "^18.2.64",
46
54
  "@types/react-dom": "^18.2.21",
47
- "@vitejs/plugin-react": "^6.0.1",
48
- "autoprefixer": "^10.4.18",
49
- "postcss": "^8.4.35",
50
- "tailwindcss": "^3.4.1",
51
- "tailwindcss-animate": "^1.0.7",
52
- "vite": "^8.0.8"
55
+ "@vitejs/plugin-react": "^6.0.2",
56
+ "postcss": "^8.5.15",
57
+ "vite": "^8.0.16"
53
58
  }
54
59
  }
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useState, useSyncExternalStore } from "react";
2
2
  import {
3
3
  BrowserRouter,
4
4
  Routes,
@@ -6,6 +6,7 @@ import {
6
6
  Link,
7
7
  useNavigate,
8
8
  } from "react-router-dom";
9
+ import { Menu } from "lucide-react";
9
10
  import {
10
11
  ApiProvider,
11
12
  ApiRegistryBuilder,
@@ -15,6 +16,7 @@ import {
15
16
  rpcApiRef,
16
17
  useApi,
17
18
  ExtensionSlot,
19
+ getLazyContribution,
18
20
  pluginRegistry,
19
21
  DashboardSlot,
20
22
  NavbarRightSlot,
@@ -44,6 +46,7 @@ import {
44
46
  import { SignalProvider } from "@checkstack/signal-frontend";
45
47
  import { SignalAutoInvalidator } from "./components/SignalAutoInvalidator";
46
48
  import { SessionProvider } from "@checkstack/auth-frontend";
49
+ import { Sidebar } from "./components/Sidebar";
47
50
  import { usePluginLifecycle } from "./hooks/usePluginLifecycle";
48
51
  import { useCommands, useGlobalShortcuts } from "@checkstack/command-frontend";
49
52
  import { AnnouncementBanner } from "@checkstack/announcement-frontend";
@@ -81,6 +84,26 @@ const queryClient = new QueryClient({
81
84
  },
82
85
  });
83
86
 
87
+ // Stable references for `useSyncExternalStore` subscription to the plugin
88
+ // registry (module scope so they don't change identity across renders).
89
+ const subscribePluginRegistry = (onChange: () => void) =>
90
+ pluginRegistry.subscribe(onChange);
91
+ const getRegisteredPlugins = () => pluginRegistry.getPlugins();
92
+
93
+ // Shared fallbacks for lazily-loaded plugin route pages. The loading state
94
+ // mirrors RouteGuard's access-loading look (usePerformance-aware spinner); the
95
+ // error state contains a failed page load instead of white-screening the shell.
96
+ const ROUTE_SUSPENSE_FALLBACK = (
97
+ <div className="h-full flex items-center justify-center p-8">
98
+ <LoadingSpinner />
99
+ </div>
100
+ );
101
+ const ROUTE_ERROR_FALLBACK = (
102
+ <div className="h-full flex items-center justify-center p-8 text-sm text-muted-foreground">
103
+ This page failed to load. Try reloading.
104
+ </div>
105
+ );
106
+
84
107
  /**
85
108
  * Component that registers global keyboard shortcuts for all commands.
86
109
  * Uses react-router's navigate for SPA navigation.
@@ -132,40 +155,61 @@ function AppContent() {
132
155
  // Enable dynamic plugin loading/unloading via signals
133
156
  const { isLowPower } = usePerformance();
134
157
  usePluginLifecycle();
158
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
135
159
 
136
160
  return (
137
161
  <BrowserRouter>
138
162
  {/* Global keyboard shortcuts for commands */}
139
163
  <GlobalShortcuts />
140
164
  <AmbientBackground className="text-foreground font-sans">
141
- <AnnouncementBanner />
142
- <header className={cn(
143
- "p-4 shadow-sm border-b border-border z-50 relative",
144
- isLowPower ? "bg-card" : "bg-card/80 backdrop-blur-sm"
145
- )}>
146
- <div className="flex items-center justify-between gap-4">
147
- {/* Left: Logo and main navigation */}
148
- <div className="flex items-center gap-8 flex-shrink-0">
149
- <Link to="/" className="flex items-center gap-2">
150
- <img src="/favicon.svg" alt="" className="w-7 h-7" />
151
- <h1 className="text-xl font-bold text-primary">Checkstack</h1>
152
- </Link>
153
- <nav className="hidden md:flex gap-1">
154
- <ExtensionSlot slot={NavbarLeftSlot} />
155
- </nav>
156
- </div>
157
- {/* Center: Search (flexible width, centered) */}
158
- <div className="flex-1 flex justify-center max-w-md">
159
- <ExtensionSlot slot={NavbarCenterSlot} />
160
- </div>
161
- {/* Right: Other navbar items */}
162
- <div className="flex gap-2 flex-shrink-0">
163
- <ExtensionSlot slot={NavbarRightSlot} />
164
- </div>
165
- </div>
166
- </header>
167
- <main className="px-3 py-4 md:p-8 max-w-7xl mx-auto">
168
- <Routes>
165
+ {/* App shell: a full-height column - the header spans the FULL width on
166
+ top, and below it a row holds the left nav (persistent on desktop,
167
+ drawer on mobile) beside the scrollable content. */}
168
+ <div className="flex flex-col h-screen overflow-hidden">
169
+ <AnnouncementBanner />
170
+ <header
171
+ className={cn(
172
+ "shrink-0 p-4 shadow-sm border-b border-border z-40 relative",
173
+ isLowPower ? "bg-card" : "bg-card/80 backdrop-blur-sm",
174
+ )}
175
+ >
176
+ <div className="flex items-center justify-between gap-4">
177
+ {/* Left: hamburger (mobile), logo, optional navbar-left slot */}
178
+ <div className="flex items-center gap-4 md:gap-8 flex-shrink-0">
179
+ <button
180
+ type="button"
181
+ onClick={() => setMobileNavOpen(true)}
182
+ aria-label="Open navigation"
183
+ className="md:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
184
+ >
185
+ <Menu className="h-5 w-5" />
186
+ </button>
187
+ <Link to="/" className="flex items-center gap-2">
188
+ <img src="/favicon.svg" alt="" className="w-7 h-7" />
189
+ <h1 className="text-xl font-bold text-primary">Checkstack</h1>
190
+ </Link>
191
+ <nav className="hidden md:flex gap-1">
192
+ <ExtensionSlot slot={NavbarLeftSlot} />
193
+ </nav>
194
+ </div>
195
+ {/* Center: Search (flexible width, centered) */}
196
+ <div className="flex-1 flex justify-center max-w-md">
197
+ <ExtensionSlot slot={NavbarCenterSlot} />
198
+ </div>
199
+ {/* Right: Other navbar items */}
200
+ <div className="flex items-center gap-2 flex-shrink-0">
201
+ <ExtensionSlot slot={NavbarRightSlot} />
202
+ </div>
203
+ </div>
204
+ </header>
205
+ <div className="flex flex-1 min-h-0">
206
+ <Sidebar
207
+ mobileOpen={mobileNavOpen}
208
+ onMobileOpenChange={setMobileNavOpen}
209
+ />
210
+ <main className="flex-1 min-w-0 overflow-y-auto">
211
+ <div className="px-3 py-4 md:p-8 w-full max-w-7xl mx-auto">
212
+ <Routes>
169
213
  <Route
170
214
  path="/"
171
215
  element={
@@ -174,22 +218,46 @@ function AppContent() {
174
218
  </div>
175
219
  }
176
220
  />
177
- {/* Plugin Routes */}
178
- {pluginRegistry.getAllRoutes().map((route) => (
179
- <Route
180
- key={route.path}
181
- path={route.path}
182
- element={
183
- <RouteGuard accessRule={route.accessRule}>
184
- {route.element}
185
- </RouteGuard>
186
- }
187
- />
188
- ))}
189
- {/* Catch-all: show Not Found for unmatched routes */}
190
- <Route path="*" element={<NotFound />} />
191
- </Routes>
192
- </main>
221
+ {/* Plugin Routes. Each plugin declares its page via a `load` thunk;
222
+ the framework (`getLazyContribution`) code-splits it and wraps it
223
+ in Suspense + a per-plugin error boundary, so the page chunk is
224
+ fetched on navigation (not in the initial load) and a failing
225
+ page degrades gracefully instead of crashing the shell. */}
226
+ {pluginRegistry.getAllRoutes().map((route) => {
227
+ // A route is either lazy (`load`, the default — framework wraps it
228
+ // in Suspense + error boundary) or eager (`element`, rare — e.g.
229
+ // the login page on the critical path).
230
+ let element: React.ReactNode;
231
+ if (route.load) {
232
+ const PageElement = getLazyContribution({
233
+ id: route.path,
234
+ load: route.load,
235
+ suspenseFallback: ROUTE_SUSPENSE_FALLBACK,
236
+ errorFallback: ROUTE_ERROR_FALLBACK,
237
+ });
238
+ element = <PageElement />;
239
+ } else {
240
+ element = route.element;
241
+ }
242
+ return (
243
+ <Route
244
+ key={route.path}
245
+ path={route.path}
246
+ element={
247
+ <RouteGuard accessRule={route.accessRule}>
248
+ {element}
249
+ </RouteGuard>
250
+ }
251
+ />
252
+ );
253
+ })}
254
+ {/* Catch-all: show Not Found for unmatched routes */}
255
+ <Route path="*" element={<NotFound />} />
256
+ </Routes>
257
+ </div>
258
+ </main>
259
+ </div>
260
+ </div>
193
261
  </AmbientBackground>
194
262
  </BrowserRouter>
195
263
  );
@@ -204,6 +272,15 @@ function AppWithApis() {
204
272
  useRuntimeConfigContext();
205
273
  const { baseUrl } = useRuntimeConfig();
206
274
 
275
+ // Subscribe to the plugin registry so the API registry below rebuilds when
276
+ // plugins register/unregister at runtime (e.g. a remotely-installed plugin),
277
+ // picking up their `apis` factories. `getPlugins()` returns an immutable
278
+ // snapshot whose reference changes on every registry change.
279
+ const plugins = useSyncExternalStore(
280
+ subscribePluginRegistry,
281
+ getRegisteredPlugins,
282
+ );
283
+
207
284
  const apiRegistry = useMemo(() => {
208
285
  // Initialize API Registry with core apiRefs
209
286
  const registryBuilder = new ApiRegistryBuilder()
@@ -219,7 +296,6 @@ function AppWithApis() {
219
296
  });
220
297
 
221
298
  // Register API factories from plugins
222
- const plugins = pluginRegistry.getPlugins();
223
299
  for (const plugin of plugins) {
224
300
  if (plugin.apis) {
225
301
  for (const api of plugin.apis) {
@@ -235,7 +311,7 @@ function AppWithApis() {
235
311
  }
236
312
 
237
313
  return registryBuilder.build();
238
- }, [baseUrl]);
314
+ }, [baseUrl, plugins]);
239
315
 
240
316
  // Show spinner while fetching runtime config and probing baseUrl.
241
317
  if (isConfigLoading) {
@@ -0,0 +1,248 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { NavLink, useLocation } from "react-router-dom";
3
+ import { pluginRegistry } from "@checkstack/frontend-api";
4
+ import { useAccessRules } from "@checkstack/auth-frontend";
5
+ import {
6
+ isAccessRuleSatisfied,
7
+ APP_DOC_SLUGS,
8
+ docsPath,
9
+ } from "@checkstack/common";
10
+ import {
11
+ Sheet,
12
+ SheetContent,
13
+ SheetHeader,
14
+ SheetTitle,
15
+ cn,
16
+ usePerformance,
17
+ } from "@checkstack/ui";
18
+ import { LayoutDashboard, ChevronDown, BookOpen } from "lucide-react";
19
+
20
+ const DOCUMENTATION_GROUP = "Documentation";
21
+
22
+ /**
23
+ * Fixed display order for the known nav sections. Unknown groups (e.g. from a
24
+ * third-party plugin) are appended after these, alphabetically.
25
+ */
26
+ const GROUP_ORDER = [
27
+ "Workspace",
28
+ "Reliability",
29
+ "Automation",
30
+ "Configuration",
31
+ "Documentation",
32
+ ] as const;
33
+
34
+ const COLLAPSED_GROUPS_KEY = "checkstack.sidebar.collapsedGroups";
35
+
36
+ type AppRoute = ReturnType<typeof pluginRegistry.getAllRoutes>[number];
37
+ type NavRoute = AppRoute & { nav: NonNullable<AppRoute["nav"]> };
38
+
39
+ interface NavGroup {
40
+ group: string;
41
+ items: NavRoute[];
42
+ }
43
+
44
+ /** Build the access-filtered, grouped, ordered nav model from the route registry. */
45
+ function useNavGroups(): NavGroup[] {
46
+ const { accessRules } = useAccessRules();
47
+
48
+ // getAllRoutes() is recomputed from the registry; plugin load/unload triggers
49
+ // an App re-render so this stays current. Cheap O(routes) work.
50
+ const routes = pluginRegistry.getAllRoutes();
51
+
52
+ return useMemo(() => {
53
+ const visible = routes.filter(
54
+ (route): route is NavRoute =>
55
+ route.nav !== undefined &&
56
+ (route.nav.accessRule === undefined ||
57
+ isAccessRuleSatisfied(accessRules, route.nav.accessRule)),
58
+ );
59
+
60
+ const byGroup = new Map<string, NavRoute[]>();
61
+ for (const route of visible) {
62
+ const list = byGroup.get(route.nav.group);
63
+ if (list) list.push(route);
64
+ else byGroup.set(route.nav.group, [route]);
65
+ }
66
+
67
+ const orderOf = (group: string): number => {
68
+ const index = GROUP_ORDER.indexOf(group as (typeof GROUP_ORDER)[number]);
69
+ return index === -1 ? GROUP_ORDER.length : index;
70
+ };
71
+
72
+ return [...byGroup.entries()]
73
+ .toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
74
+ .map(([group, items]) => ({
75
+ group,
76
+ items: items.toSorted(
77
+ (a, b) =>
78
+ a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
79
+ ),
80
+ }));
81
+ }, [routes, accessRules]);
82
+ }
83
+
84
+ function loadCollapsedGroups(): Set<string> {
85
+ try {
86
+ const raw = globalThis.localStorage?.getItem(COLLAPSED_GROUPS_KEY);
87
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
88
+ return new Set(Array.isArray(parsed) ? parsed.filter((g) => typeof g === "string") : []);
89
+ } catch {
90
+ return new Set();
91
+ }
92
+ }
93
+
94
+ function navItemClass(isActive: boolean): string {
95
+ return cn(
96
+ "flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
97
+ isActive
98
+ ? "bg-primary/10 text-primary font-medium"
99
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
100
+ );
101
+ }
102
+
103
+ interface NavListProps {
104
+ /** Invoked after a nav link is clicked (used to close the mobile drawer). */
105
+ onNavigate?: () => void;
106
+ }
107
+
108
+ /** The shared nav content rendered in both the desktop rail and the mobile drawer. */
109
+ function NavList({ onNavigate }: NavListProps): React.ReactElement {
110
+ const { isLowPower } = usePerformance();
111
+ const groups = useNavGroups();
112
+ const location = useLocation();
113
+ const [collapsed, setCollapsed] = useState<Set<string>>(loadCollapsedGroups);
114
+
115
+ // The in-app user guide is a shell-owned external entry (the backend serves
116
+ // the static Astro docs at /checkstack/* same-origin), so it lives under the
117
+ // Documentation group here rather than as a navbar link. Ensure the group
118
+ // renders even when no plugin route contributes to it.
119
+ const groupsToRender = useMemo(
120
+ () =>
121
+ groups.some((g) => g.group === DOCUMENTATION_GROUP)
122
+ ? groups
123
+ : [...groups, { group: DOCUMENTATION_GROUP, items: [] }],
124
+ [groups],
125
+ );
126
+
127
+ const toggleGroup = (group: string): void => {
128
+ setCollapsed((prev) => {
129
+ const next = new Set(prev);
130
+ if (next.has(group)) next.delete(group);
131
+ else next.add(group);
132
+ try {
133
+ globalThis.localStorage?.setItem(
134
+ COLLAPSED_GROUPS_KEY,
135
+ JSON.stringify([...next]),
136
+ );
137
+ } catch {
138
+ // localStorage unavailable (private mode) - in-memory state is enough.
139
+ }
140
+ return next;
141
+ });
142
+ };
143
+
144
+ return (
145
+ <nav className="flex flex-col gap-1 p-3" aria-label="Primary">
146
+ {/* Dashboard / home is the one entry the shell owns (route "/"). */}
147
+ <NavLink to="/" end className={({ isActive }) => navItemClass(isActive)} onClick={onNavigate}>
148
+ <LayoutDashboard className="h-4 w-4 shrink-0" />
149
+ <span className="truncate">Dashboard</span>
150
+ </NavLink>
151
+
152
+ {groupsToRender.map(({ group, items }) => {
153
+ const isCollapsed = collapsed.has(group);
154
+ const isDocumentation = group === DOCUMENTATION_GROUP;
155
+ return (
156
+ <div key={group} className="mt-2">
157
+ <button
158
+ type="button"
159
+ onClick={() => toggleGroup(group)}
160
+ aria-expanded={!isCollapsed}
161
+ className="flex w-full items-center justify-between px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/70 hover:text-muted-foreground"
162
+ >
163
+ <span className="truncate">{group}</span>
164
+ <ChevronDown
165
+ className={cn(
166
+ "h-3.5 w-3.5 shrink-0",
167
+ !isLowPower && "transition-transform",
168
+ isCollapsed && "-rotate-90",
169
+ )}
170
+ aria-hidden="true"
171
+ />
172
+ </button>
173
+ {!isCollapsed && (
174
+ <div className="mt-1 flex flex-col gap-0.5">
175
+ {items.map((route) => {
176
+ const Icon = route.nav.icon;
177
+ const active =
178
+ location.pathname === route.path ||
179
+ location.pathname.startsWith(`${route.path}/`);
180
+ return (
181
+ <NavLink
182
+ key={route.path}
183
+ to={route.path}
184
+ className={navItemClass(active)}
185
+ onClick={onNavigate}
186
+ >
187
+ <Icon className="h-4 w-4 shrink-0" />
188
+ <span className="truncate">{route.nav.label}</span>
189
+ </NavLink>
190
+ );
191
+ })}
192
+ {isDocumentation && (
193
+ <a
194
+ href={docsPath(APP_DOC_SLUGS.userGuideHome)}
195
+ target="_blank"
196
+ rel="noreferrer"
197
+ title="Open the user guide"
198
+ className={navItemClass(false)}
199
+ onClick={onNavigate}
200
+ >
201
+ <BookOpen className="h-4 w-4 shrink-0" />
202
+ <span className="truncate">Docs</span>
203
+ </a>
204
+ )}
205
+ </div>
206
+ )}
207
+ </div>
208
+ );
209
+ })}
210
+ </nav>
211
+ );
212
+ }
213
+
214
+ export interface SidebarProps {
215
+ /** Whether the mobile drawer is open. */
216
+ mobileOpen: boolean;
217
+ onMobileOpenChange: (open: boolean) => void;
218
+ }
219
+
220
+ /**
221
+ * Primary app navigation. A persistent rail on >= md screens; a slide-over
222
+ * drawer (reusing the UI `Sheet`) on small screens, controlled by the navbar
223
+ * hamburger. Nav entries come from routes that declare `nav` metadata and are
224
+ * filtered by the user's access rules.
225
+ */
226
+ export function Sidebar({
227
+ mobileOpen,
228
+ onMobileOpenChange,
229
+ }: SidebarProps): React.ReactElement {
230
+ return (
231
+ <>
232
+ {/* Fills the shell row (height comes from the flex parent); scrolls
233
+ independently of the main content. */}
234
+ <aside className="hidden md:flex flex-col w-60 shrink-0 border-r border-border overflow-y-auto">
235
+ <NavList />
236
+ </aside>
237
+
238
+ <Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}>
239
+ <SheetContent className="p-0 overflow-y-auto">
240
+ <SheetHeader className="px-4 py-3 border-b border-border">
241
+ <SheetTitle className="text-base">Navigation</SheetTitle>
242
+ </SheetHeader>
243
+ <NavList onNavigate={() => onMobileOpenChange(false)} />
244
+ </SheetContent>
245
+ </Sheet>
246
+ </>
247
+ );
248
+ }
@@ -1,11 +1,21 @@
1
- import { describe, it, expect, mock, beforeEach } from "bun:test";
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ mock,
6
+ beforeAll,
7
+ beforeEach,
8
+ afterAll,
9
+ } from "bun:test";
2
10
  import { loadPlugins } from "./plugin-loader";
3
11
 
4
12
  // Note: We don't mock @checkstack/frontend-api module-wide here because
5
13
  // it causes test isolation issues with other tests that use the real pluginRegistry.
6
14
  // Instead, we just verify behavior based on the function's outputs.
7
15
 
8
- // Mock fetch
16
+ // Mock fetch. The `typeof url === "string"` guard means a stray non-string
17
+ // argument can never throw (defensive; the override is also scoped to this
18
+ // suite's lifecycle below).
9
19
  const mockFetch = mock((url: string) => {
10
20
  if (url === "/api/plugins") {
11
21
  return Promise.resolve({
@@ -14,21 +24,32 @@ const mockFetch = mock((url: string) => {
14
24
  } as unknown as Response);
15
25
  }
16
26
  // Mock HEAD request for CSS
17
- if (url.endsWith(".css")) {
27
+ if (typeof url === "string" && url.endsWith(".css")) {
18
28
  return Promise.resolve({ ok: true } as unknown as Response);
19
29
  }
20
30
  return Promise.resolve({ ok: false } as unknown as Response);
21
31
  });
22
32
 
23
- (global as any).fetch = mockFetch;
33
+ // bun test runs every file in ONE shared process, so leaving these overrides on
34
+ // `global` leaks into other files - e.g. the real-HTTP backend auth tests would
35
+ // hit this mock instead of the real fetch and crash. Scope them to this suite.
36
+ const originalFetch = global.fetch;
37
+ const originalDocument = global.document;
24
38
 
25
- // Mock document
26
- global.document = {
27
- createElement: mock(() => ({})),
28
- head: {
29
- append: mock(),
30
- },
31
- } as unknown as Document;
39
+ beforeAll(() => {
40
+ (global as unknown as { fetch: typeof fetch }).fetch = mockFetch as never;
41
+ global.document = {
42
+ createElement: mock(() => ({})),
43
+ head: {
44
+ append: mock(),
45
+ },
46
+ } as unknown as Document;
47
+ });
48
+
49
+ afterAll(() => {
50
+ (global as unknown as { fetch: typeof fetch }).fetch = originalFetch;
51
+ (global as unknown as { document: Document }).document = originalDocument;
52
+ });
32
53
 
33
54
  describe("frontend loadPlugins", () => {
34
55
  beforeEach(() => {
@@ -62,7 +62,12 @@ describe("PluginRegistry", () => {
62
62
  it("should aggregate all routes from registered plugins", () => {
63
63
  const pluginB: FrontendPlugin = {
64
64
  metadata: { pluginId: "plugin-b" },
65
- routes: [{ route: pluginBRoutes.routes.home }],
65
+ routes: [
66
+ {
67
+ route: pluginBRoutes.routes.home,
68
+ element: React.createElement("div", null, "Plugin B Route"),
69
+ },
70
+ ],
66
71
  };
67
72
 
68
73
  pluginRegistry.register(mockPlugin);
@@ -105,4 +110,56 @@ describe("PluginRegistry", () => {
105
110
  expect(extensions.map((e) => e.id)).toContain("ext-a");
106
111
  expect(extensions.map((e) => e.id)).toContain("ext-b");
107
112
  });
113
+
114
+ it("getAllRoutes preserves both eager (element) and lazy (load) routes", () => {
115
+ const loader = () =>
116
+ Promise.resolve({
117
+ default: () => React.createElement("div", null, "Lazy"),
118
+ });
119
+ const plugin: FrontendPlugin = {
120
+ metadata: { pluginId: "test" },
121
+ routes: [
122
+ { route: testRoutes.routes.home, element: React.createElement("div") },
123
+ { route: testRoutes.routes.config, load: loader },
124
+ ],
125
+ };
126
+ pluginRegistry.register(plugin);
127
+
128
+ const routes = pluginRegistry.getAllRoutes();
129
+ const home = routes.find((r) => r.path === "/test/");
130
+ const config = routes.find((r) => r.path === "/test/config");
131
+ expect(home?.element).toBeDefined();
132
+ expect(home?.load).toBeUndefined();
133
+ expect(config?.load).toBe(loader);
134
+ expect(config?.element).toBeUndefined();
135
+ });
136
+
137
+ it("getPlugins returns a NEW snapshot reference on every change", () => {
138
+ // Required for useSyncExternalStore / useMemo consumers to recompute when
139
+ // a plugin is added or removed at runtime.
140
+ const before = pluginRegistry.getPlugins();
141
+ pluginRegistry.register(mockPlugin);
142
+ const afterRegister = pluginRegistry.getPlugins();
143
+ expect(afterRegister).not.toBe(before);
144
+ expect(afterRegister).toContain(mockPlugin);
145
+
146
+ pluginRegistry.unregister("test");
147
+ const afterUnregister = pluginRegistry.getPlugins();
148
+ expect(afterUnregister).not.toBe(afterRegister);
149
+ expect(afterUnregister).not.toContain(mockPlugin);
150
+ });
151
+
152
+ it("notifies subscribers on register and unregister", () => {
153
+ let calls = 0;
154
+ const unsubscribe = pluginRegistry.subscribe(() => {
155
+ calls += 1;
156
+ });
157
+ pluginRegistry.register(mockPlugin);
158
+ pluginRegistry.unregister("test");
159
+ expect(calls).toBe(2);
160
+
161
+ unsubscribe();
162
+ pluginRegistry.register(mockPlugin);
163
+ expect(calls).toBe(2); // no longer notified after unsubscribe
164
+ });
108
165
  });
@@ -0,0 +1,87 @@
1
+ import tailwindcssAnimate from "tailwindcss-animate";
2
+
3
+ /**
4
+ * Shared Checkstack Tailwind preset: the platform's design tokens (colors
5
+ * mapped to the `@checkstack/ui` CSS variables, border radii) and the
6
+ * `tailwindcss-animate` plugin. Deliberately carries NO `content` — a
7
+ * preset must not, so each consumer (the frontend app, the standalone dev
8
+ * server, a plugin author's own Tailwind setup) supplies its own content
9
+ * globs.
10
+ *
11
+ * Exported as a public subpath (`@checkstack/frontend/tailwind-preset`) so
12
+ * the standalone dev server can build the dev shell with the platform theme
13
+ * WITHOUT reaching into frontend internals, and so plugin authors can
14
+ * `presets: [require("@checkstack/frontend/tailwind-preset")]` in their own
15
+ * config. `tailwindcss-animate` resolves from `@checkstack/frontend`'s own
16
+ * (runtime) dependency scope, so this loads correctly from a published
17
+ * install.
18
+ *
19
+ * @type {import('tailwindcss').Config}
20
+ */
21
+ export default {
22
+ darkMode: ["class"],
23
+ theme: {
24
+ extend: {
25
+ colors: {
26
+ border: "hsl(var(--border))",
27
+ input: "hsl(var(--input))",
28
+ ring: "hsl(var(--ring))",
29
+ background: "hsl(var(--background))",
30
+ foreground: "hsl(var(--foreground))",
31
+ primary: {
32
+ DEFAULT: "hsl(var(--primary))",
33
+ foreground: "hsl(var(--primary-foreground))",
34
+ },
35
+ secondary: {
36
+ DEFAULT: "hsl(var(--secondary))",
37
+ foreground: "hsl(var(--secondary-foreground))",
38
+ },
39
+ destructive: {
40
+ DEFAULT: "hsl(var(--destructive))",
41
+ foreground: "hsl(var(--destructive-foreground))",
42
+ },
43
+ muted: {
44
+ DEFAULT: "hsl(var(--muted))",
45
+ foreground: "hsl(var(--muted-foreground))",
46
+ },
47
+ accent: {
48
+ DEFAULT: "hsl(var(--accent))",
49
+ foreground: "hsl(var(--accent-foreground))",
50
+ },
51
+ popover: {
52
+ DEFAULT: "hsl(var(--popover))",
53
+ foreground: "hsl(var(--popover-foreground))",
54
+ },
55
+ card: {
56
+ DEFAULT: "hsl(var(--card))",
57
+ foreground: "hsl(var(--card-foreground))",
58
+ },
59
+ chart: {
60
+ 1: "hsl(var(--chart-1))",
61
+ 2: "hsl(var(--chart-2))",
62
+ 3: "hsl(var(--chart-3))",
63
+ 4: "hsl(var(--chart-4))",
64
+ 5: "hsl(var(--chart-5))",
65
+ },
66
+ success: {
67
+ DEFAULT: "hsl(var(--success))",
68
+ foreground: "hsl(var(--success-foreground))",
69
+ },
70
+ warning: {
71
+ DEFAULT: "hsl(var(--warning))",
72
+ foreground: "hsl(var(--warning-foreground))",
73
+ },
74
+ info: {
75
+ DEFAULT: "hsl(var(--info))",
76
+ foreground: "hsl(var(--info-foreground))",
77
+ },
78
+ },
79
+ borderRadius: {
80
+ lg: "var(--radius)",
81
+ md: "calc(var(--radius) - 2px)",
82
+ sm: "calc(var(--radius) - 4px)",
83
+ },
84
+ },
85
+ },
86
+ plugins: [tailwindcssAnimate],
87
+ };
@@ -1,8 +1,11 @@
1
- import tailwindcssAnimate from "tailwindcss-animate";
1
+ import checkstackPreset from "./tailwind-preset.js";
2
2
 
3
3
  /** @type {import('tailwindcss').Config} */
4
4
  export default {
5
- darkMode: ["class"],
5
+ // Theme tokens + tailwindcss-animate live in the shared preset (also
6
+ // exported as `@checkstack/frontend/tailwind-preset`). Only `content`
7
+ // is app-specific.
8
+ presets: [checkstackPreset],
6
9
  content: [
7
10
  "./index.html",
8
11
  "./src/**/*.{js,ts,jsx,tsx}",
@@ -13,68 +16,4 @@ export default {
13
16
  // Shared UI library
14
17
  "../ui/src/**/*.{js,ts,jsx,tsx}",
15
18
  ],
16
- theme: {
17
- extend: {
18
- colors: {
19
- border: "hsl(var(--border))",
20
- input: "hsl(var(--input))",
21
- ring: "hsl(var(--ring))",
22
- background: "hsl(var(--background))",
23
- foreground: "hsl(var(--foreground))",
24
- primary: {
25
- DEFAULT: "hsl(var(--primary))",
26
- foreground: "hsl(var(--primary-foreground))",
27
- },
28
- secondary: {
29
- DEFAULT: "hsl(var(--secondary))",
30
- foreground: "hsl(var(--secondary-foreground))",
31
- },
32
- destructive: {
33
- DEFAULT: "hsl(var(--destructive))",
34
- foreground: "hsl(var(--destructive-foreground))",
35
- },
36
- muted: {
37
- DEFAULT: "hsl(var(--muted))",
38
- foreground: "hsl(var(--muted-foreground))",
39
- },
40
- accent: {
41
- DEFAULT: "hsl(var(--accent))",
42
- foreground: "hsl(var(--accent-foreground))",
43
- },
44
- popover: {
45
- DEFAULT: "hsl(var(--popover))",
46
- foreground: "hsl(var(--popover-foreground))",
47
- },
48
- card: {
49
- DEFAULT: "hsl(var(--card))",
50
- foreground: "hsl(var(--card-foreground))",
51
- },
52
- chart: {
53
- 1: "hsl(var(--chart-1))",
54
- 2: "hsl(var(--chart-2))",
55
- 3: "hsl(var(--chart-3))",
56
- 4: "hsl(var(--chart-4))",
57
- 5: "hsl(var(--chart-5))",
58
- },
59
- success: {
60
- DEFAULT: "hsl(var(--success))",
61
- foreground: "hsl(var(--success-foreground))",
62
- },
63
- warning: {
64
- DEFAULT: "hsl(var(--warning))",
65
- foreground: "hsl(var(--warning-foreground))",
66
- },
67
- info: {
68
- DEFAULT: "hsl(var(--info))",
69
- foreground: "hsl(var(--info-foreground))",
70
- },
71
- },
72
- borderRadius: {
73
- lg: "var(--radius)",
74
- md: "calc(var(--radius) - 2px)",
75
- sm: "calc(var(--radius) - 4px)",
76
- },
77
- },
78
- },
79
- plugins: [tailwindcssAnimate],
80
19
  };
package/vite.config.ts CHANGED
@@ -59,6 +59,11 @@ export default defineConfig(() => {
59
59
  // here for dev convenience so e.g.
60
60
  // `curl http://localhost:5173/.checkstack/ready` works.
61
61
  "/.checkstack": backendUrl,
62
+ // In-app user guide: the backend serves the Astro docs build at
63
+ // /checkstack/* (distinct from /.checkstack above). Proxy it so the
64
+ // dev SPA shows the same docs. Requires the docs to be built once:
65
+ // `bun run --filter @checkstack/docs build`.
66
+ "/checkstack": backendUrl,
62
67
  },
63
68
  },
64
69
  // ============================================================
@@ -103,6 +108,56 @@ export default defineConfig(() => {
103
108
  // Don't wipe dist/ — the vendor build (build:vendor) writes to dist/vendor/
104
109
  // before this build runs, and we need to preserve those files
105
110
  emptyOutDir: false,
111
+ rollupOptions: {
112
+ output: {
113
+ // Split heavy / stable vendor code into dedicated chunks so the
114
+ // initial (login) load stays small and chunks cache independently.
115
+ // This complements the `React.lazy(CodeEditor)` split: it does not by
116
+ // itself keep Monaco off the login page (that's the lazy boundary in
117
+ // CodeEditor.tsx), but it guarantees the whole `@codingame/*` /
118
+ // monaco stack lands in ONE chunk that is only fetched when an editor
119
+ // mounts, rather than smeared across many shared chunks.
120
+ manualChunks(id) {
121
+ if (!id.includes("node_modules")) {
122
+ return;
123
+ }
124
+ // NB: do NOT try to hand-group lucide icon modules here. The same
125
+ // per-icon modules are reached BOTH statically (`import { Plus }
126
+ // from "lucide-react"` across the app, which must stay eager) and
127
+ // dynamically (DynamicIcon's lazy icon registry). A manualChunk
128
+ // keys off module id only, so it can't tell the two apart and ends
129
+ // up pulling the whole icon set into the eager graph. The lazy
130
+ // boundary for the data-driven icon set lives in DynamicIcon /
131
+ // iconRegistry instead (see core/ui).
132
+ // NOTE: we deliberately do NOT hand-group the Monaco / VS Code
133
+ // editor stack here. Rollup's natural code-splitting already
134
+ // isolates it: every `@codingame/*` / `@typefox/*` /
135
+ // monaco-languageclient module is reachable only through the lazy
136
+ // `CodeEditor` / `validateScripts` boundaries (see CodeEditor.tsx),
137
+ // so it lands in dynamically-imported chunks that the initial
138
+ // (login) load never fetches. A manual `monaco` chunk is actively
139
+ // harmful: a tiny `@codingame/*` module is pulled in EAGERLY as a
140
+ // transitive dep of non-editor code, and folding it into one big
141
+ // chunk with the lazy editor body makes the entire ~10 MB chunk a
142
+ // static dependency of the entry, re-shipping Monaco to the login
143
+ // page. Leaving it to natural splitting keeps the heavy editor body
144
+ // lazy while that tiny eager stub stays inlined where it belongs.
145
+
146
+ // The React runtime: one stable chunk shared across the whole app.
147
+ // `dedupe` above already guarantees a single copy; this only
148
+ // controls which output file it lands in.
149
+ if (
150
+ id.includes("/react/") ||
151
+ id.includes("/react-dom/") ||
152
+ id.includes("/react-router") ||
153
+ id.includes("/scheduler/")
154
+ ) {
155
+ return "react-vendor";
156
+ }
157
+ return;
158
+ },
159
+ },
160
+ },
106
161
  },
107
162
  resolve: {
108
163
  // Force all monorepo packages to use the same React copy at build time.