@fasl-work/caos-app-shell 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ fix lands in one place.
13
13
  ```bash
14
14
  npm i @fasl-work/caos-app-shell
15
15
  # peer deps (the app provides them):
16
- npm i react react-dom react-router-dom lucide-react katex zustand
16
+ npm i react react-dom react-router-dom lucide-react katex zustand
17
17
  ```
18
18
 
19
19
  ## Use
@@ -60,9 +60,20 @@ const config: ShellConfig = {
60
60
 
61
61
  ## Exports
62
62
 
63
- `AppShell`, `ThemeToggle`, `LanguageToggle`, `useThemeStore`, `applyTheme`, `readTheme`, `useLangStore`,
63
+ `AppShell`, `WorkbenchShell`, `ThemeToggle`, `LanguageToggle`, `useThemeStore`, `applyTheme`, `readTheme`, `useLangStore`,
64
64
  `useShellLang`, `usePausedViz`, `createVizLoop`, `CaseSelector` (+ `caseModel` helpers), `Tabs`, `SubTabs`,
65
65
  `Callout`, `Equation`, `InlineMath`, `Figure`, `CitationsProvider`, `Cite`, `Refs`, `ReferenceList`, plus
66
- the `@fasl-work/caos-app-shell/styles.css` design system.
66
+ the `@fasl-work/caos-app-shell/styles.css` design system.
67
+
68
+ ### Authenticated workbenches
69
+
70
+ `WorkbenchShell` is the shared frame for products whose authenticated UI needs a desktop sidebar and
71
+ mobile bottom navigation. It renders typed routes and the main landmark while the product supplies its
72
+ own stateful controls through `brand`, `sidebarFooter`, `headerLead`, `headerActions`, and `overlays`.
73
+ Authentication, account policy, preference persistence, chat, and modal content remain product-owned.
74
+ The component uses the stable core `NavLink` contract and supports `react-router` 6, 7, and 8.
75
+ Browser applications on 6/7 install the matching `react-router-dom`, which supplies the core peer;
76
+ Router 8 applications provide `react-router` directly. Do not mix different core and DOM majors in
77
+ one application.
67
78
 
68
79
  MIT · part of the Faena mining-analytics hub.
package/dist/index.d.ts CHANGED
@@ -68,6 +68,36 @@ declare function AppShell({ config, children }: {
68
68
  children: ReactNode;
69
69
  }): react.JSX.Element;
70
70
 
71
+ /** One authenticated workbench destination rendered by the shared responsive navigation frame. */
72
+ interface WorkbenchRoute {
73
+ path: string;
74
+ label: ReactNode;
75
+ icon?: ReactNode;
76
+ end?: boolean;
77
+ ariaLabel?: string;
78
+ }
79
+ /**
80
+ * Slots owned by a product around the shared workbench layout.
81
+ *
82
+ * The shell deliberately does not own authentication, preferences, account state, chat, or modal
83
+ * content. Products supply those controls as slots while this package owns the frame, navigation
84
+ * semantics, responsive layout classes, and main-content landmark.
85
+ */
86
+ interface WorkbenchShellProps {
87
+ brand: ReactNode;
88
+ routes: WorkbenchRoute[];
89
+ navigationLabel: string;
90
+ children: ReactNode;
91
+ sidebarFooter?: ReactNode;
92
+ headerLead?: ReactNode;
93
+ headerActions?: ReactNode;
94
+ overlays?: ReactNode;
95
+ mainId?: string;
96
+ className?: string;
97
+ }
98
+ /** Shared CAOS frame for authenticated consoles with desktop sidebar and mobile bottom navigation. */
99
+ declare function WorkbenchShell({ brand, routes, navigationLabel, children, sidebarFooter, headerLead, headerActions, overlays, mainId, className, }: WorkbenchShellProps): react.JSX.Element;
100
+
71
101
  /** Light/dark icon toggle. Shows the icon of the theme you would switch TO. */
72
102
  declare function ThemeToggle(): react.JSX.Element;
73
103
 
@@ -316,4 +346,4 @@ declare function ReferenceList({ ids, heading }: {
316
346
  heading?: string;
317
347
  }): react.JSX.Element;
318
348
 
319
- export { AppShell, type ArchTab, type ArchitectureConfig, ArchitectureModal, Callout, type CaseDef, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, type Citation, CitationsProvider, Cite, Equation, Figure, InlineMath, type Lang, LanguageToggle, type PausedVizController, ReferenceList, Refs, type ShellConfig, type ShellRoute, type SubTabDef, SubTabs, type TabDef, Tabs, type Theme, ThemeToggle, type UsePausedVizOptions, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, groupByCategory, readCaseParam, readTheme, sourcesPresent, useLangStore, usePausedViz, useShellLang, useThemeStore, withCaseParam };
349
+ export { AppShell, type ArchTab, type ArchitectureConfig, ArchitectureModal, Callout, type CaseDef, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, type Citation, CitationsProvider, Cite, Equation, Figure, InlineMath, type Lang, LanguageToggle, type PausedVizController, ReferenceList, Refs, type ShellConfig, type ShellRoute, type SubTabDef, SubTabs, type TabDef, Tabs, type Theme, ThemeToggle, type UsePausedVizOptions, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, groupByCategory, readCaseParam, readTheme, sourcesPresent, useLangStore, usePausedViz, useShellLang, useThemeStore, withCaseParam };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useState, useRef, useEffect, useCallback, useId, useContext } from 'react';
2
- import { NavLink } from 'react-router-dom';
2
+ import { NavLink } from 'react-router';
3
3
  import { Sun, Moon, Languages, Boxes, Github, Globe, Briefcase, Info } from 'lucide-react';
4
4
  import { create } from 'zustand';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
@@ -308,6 +308,55 @@ function AppShell({ config, children }) {
308
308
  ] }) }) })
309
309
  ] });
310
310
  }
311
+ function WorkbenchShell({
312
+ brand,
313
+ routes,
314
+ navigationLabel,
315
+ children,
316
+ sidebarFooter,
317
+ headerLead,
318
+ headerActions,
319
+ overlays,
320
+ mainId = "main-content",
321
+ className
322
+ }) {
323
+ const rootClassName = ["caos-workbench-shell", "app-shell", className].filter(Boolean).join(" ");
324
+ return /* @__PURE__ */ jsxs("div", { className: rootClassName, children: [
325
+ /* @__PURE__ */ jsxs(
326
+ "aside",
327
+ {
328
+ className: "caos-workbench-sidebar sidebar",
329
+ style: { "--caos-workbench-route-count": routes.length },
330
+ children: [
331
+ brand,
332
+ /* @__PURE__ */ jsx("nav", { "aria-label": navigationLabel, children: routes.map((route) => /* @__PURE__ */ jsxs(
333
+ NavLink,
334
+ {
335
+ to: route.path,
336
+ end: route.end ?? route.path === "/",
337
+ "aria-label": route.ariaLabel,
338
+ className: ({ isActive }) => isActive ? "active" : void 0,
339
+ children: [
340
+ route.icon,
341
+ /* @__PURE__ */ jsx("span", { children: route.label })
342
+ ]
343
+ },
344
+ route.path
345
+ )) }),
346
+ sidebarFooter
347
+ ]
348
+ }
349
+ ),
350
+ /* @__PURE__ */ jsxs("div", { className: "caos-workbench-main shell-main", children: [
351
+ /* @__PURE__ */ jsxs("header", { className: "caos-workbench-header topbar", children: [
352
+ headerLead,
353
+ headerActions
354
+ ] }),
355
+ /* @__PURE__ */ jsx("main", { id: mainId, className: "caos-workbench-content content", children })
356
+ ] }),
357
+ overlays
358
+ ] });
359
+ }
311
360
 
312
361
  // src/lib/vizLoop.ts
313
362
  function createVizLoop(frame, options, deps) {
@@ -788,4 +837,4 @@ function ReferenceList({ ids, heading }) {
788
837
  ] });
789
838
  }
790
839
 
791
- export { AppShell, ArchitectureModal, Callout, CaseSelector, CitationsProvider, Cite, Equation, Figure, InlineMath, LanguageToggle, ReferenceList, Refs, SubTabs, Tabs, ThemeToggle, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, groupByCategory, readCaseParam, readTheme, sourcesPresent, useLangStore, usePausedViz, useShellLang, useThemeStore, withCaseParam };
840
+ export { AppShell, ArchitectureModal, Callout, CaseSelector, CitationsProvider, Cite, Equation, Figure, InlineMath, LanguageToggle, ReferenceList, Refs, SubTabs, Tabs, ThemeToggle, WorkbenchShell, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, groupByCategory, readCaseParam, readTheme, sourcesPresent, useLangStore, usePausedViz, useShellLang, useThemeStore, withCaseParam };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared web-app shell + content primitives + design system for the CAOS / Faena public apps (ADR-0016).",
5
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "build": "tsup",
31
- "test": "node --import tsx --test test/vizLoop.test.ts test/caseModel.test.ts",
31
+ "test": "node --import tsx --test test/vizLoop.test.ts test/caseModel.test.ts test/workbenchShell.test.tsx",
32
32
  "prepublishOnly": "npm run build"
33
33
  },
34
34
  "peerDependencies": {
@@ -36,10 +36,10 @@
36
36
  "lucide-react": ">=0.400.0",
37
37
  "react": "^18 || ^19",
38
38
  "react-dom": "^18 || ^19",
39
- "react-router-dom": "^6 || ^7",
39
+ "react-router": "^6 || ^7 || ^8",
40
40
  "zustand": "^4 || ^5"
41
41
  },
42
- "devDependencies": {
42
+ "devDependencies": {
43
43
  "@types/katex": "^0.16.7",
44
44
  "@types/react": "^19.0.0",
45
45
  "@types/react-dom": "^19.0.0",
@@ -47,10 +47,13 @@
47
47
  "lucide-react": "^0.469.0",
48
48
  "react": "^19.0.0",
49
49
  "react-dom": "^19.0.0",
50
- "react-router-dom": "^7.1.1",
50
+ "react-router": "^8.3.0",
51
51
  "tsup": "^8.3.5",
52
52
  "tsx": "^4.23.0",
53
53
  "typescript": "^5.7.3",
54
- "zustand": "^5.0.2"
55
- }
56
- }
54
+ "zustand": "^5.0.2"
55
+ },
56
+ "overrides": {
57
+ "esbuild": "0.28.1"
58
+ }
59
+ }
package/styles.css CHANGED
@@ -25,7 +25,8 @@
25
25
  --color-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 16px rgba(0, 0, 0, 0.45);
26
26
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
27
27
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
28
- --maxw: 1200px;
28
+ --maxw: 1200px; /* reading width, for PROSE pages */
29
+ --maxw-wide: 100%; /* instrument width, for workbench pages */
29
30
  }
30
31
 
31
32
  [data-theme="light"] {
@@ -69,6 +70,10 @@ code, kbd { font-family: var(--font-mono); }
69
70
  .container { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: 1.5rem; }
70
71
  .page { flex: 1 1 auto; width: 100%; }
71
72
  .page-body { width: 100%; max-width: var(--maxw); margin-inline: auto; padding: 2.25rem 1.5rem 3rem; }
73
+ /* A WORKBENCH is not prose. Capping an instrument at a 1200px reading measure wastes every pixel beyond
74
+ it, which is why the App surfaces across the line render their visualization as a thumbnail on a wide
75
+ display. Prose pages keep the reading width; the App route opts into the full viewport. */
76
+ .page-body.wide { max-width: var(--maxw-wide); padding-inline: 1.25rem; }
72
77
  .muted { color: var(--color-fg-subtle); }
73
78
  .faint { color: var(--color-fg-faint); }
74
79
  .mono { font-family: var(--font-mono); font-size: 0.9em; }
@@ -155,7 +160,18 @@ code, kbd { font-family: var(--font-mono); }
155
160
  .callout-body p:last-child { margin-bottom: 0; }
156
161
 
157
162
  /* ── Tabs ── */
158
- .tablist { display: flex; gap: 0.35rem; padding-bottom: 0.6rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--color-border); flex-wrap: wrap; }
163
+ .tablist { display: flex; gap: 0.35rem; padding-bottom: 0.6rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--color-border); flex-wrap: nowrap; }
164
+ /* SINGLE-LINE TAB BAR.
165
+ `flex-wrap: wrap` lets a 14-to-18 tab bar spill onto a second and third row, and every extra row is
166
+ vertical space taken from the instrument below it, permanently, on every render. A tab bar is
167
+ navigation chrome: it gets ONE row and scrolls horizontally when it does not fit.
168
+ Scrollbar is hidden but the row stays scrollable by wheel, trackpad, drag and keyboard. */
169
+ .tablist { flex-wrap: nowrap !important; overflow-x: auto; overflow-y: hidden;
170
+ scrollbar-width: none; -ms-overflow-style: none; padding-bottom: 0.4rem; margin-bottom: 0.9rem;
171
+ scroll-behavior: smooth; }
172
+ .tablist::-webkit-scrollbar { display: none; }
173
+ .tablist .tab, .tablist button { flex: 0 0 auto; white-space: nowrap; }
174
+
159
175
  .tab { padding: 0.5rem 1rem; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--color-fg-subtle); font-size: 0.92rem; font-weight: 550; transition: color 0.15s, background-color 0.15s, border-color 0.15s; }
160
176
  .tab:hover { color: var(--color-fg); background: var(--color-surface-2); }
161
177
  .tab.active { color: var(--color-accent); background: var(--color-accent-soft); border-color: color-mix(in oklab, var(--color-accent) 35%, transparent); font-weight: 650; }
@@ -238,5 +254,50 @@ code, kbd { font-family: var(--font-mono); }
238
254
  .cs-kind { font-size: 0.62rem; font-weight: 700; line-height: 1; padding: 0.12rem 0.28rem; border-radius: 4px; border: 1px solid var(--color-border); color: var(--color-fg-faint); background: var(--color-surface-2); align-self: center; }
239
255
  .cs-kind-real { color: var(--color-accent-2); border-color: color-mix(in oklab, var(--color-accent-2) 40%, transparent); }
240
256
  .cs-kind-uploaded { color: var(--color-warn); border-color: color-mix(in oklab, var(--color-warn) 40%, transparent); }
241
- .cs-modified { display: flex; align-items: center; gap: 0.5rem; }
242
- .cs-reset { border: none; background: transparent; color: var(--color-accent); font-size: 0.8rem; font-weight: 600; padding: 0.1rem 0.2rem; text-decoration: underline; text-underline-offset: 2px; }
257
+ .cs-modified { display: flex; align-items: center; gap: 0.5rem; }
258
+ .cs-reset { border: none; background: transparent; color: var(--color-accent); font-size: 0.8rem; font-weight: 600; padding: 0.1rem 0.2rem; text-decoration: underline; text-underline-offset: 2px; }
259
+
260
+ /* Authenticated workbench frame. Product styles may refine tokens and controls, but the package owns
261
+ the responsive navigation/layout contract and keeps route/content landmarks consistent. */
262
+ .caos-workbench-shell { min-height: 100vh; display: grid; grid-template-columns: 238px minmax(0, 1fr); }
263
+ .caos-workbench-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; width: 238px; display: flex; flex-direction: column; padding: 1.25rem 0.875rem; border-right: 1px solid var(--color-border); background: var(--color-surface); }
264
+ .caos-workbench-sidebar nav { display: grid; gap: 0.25rem; }
265
+ .caos-workbench-sidebar nav a { display: flex; align-items: center; gap: 0.75rem; padding: 0.625rem 0.7rem; border: 1px solid transparent; border-radius: 0.7rem; color: var(--color-fg-subtle); font-size: 0.9rem; font-weight: 650; }
266
+ .caos-workbench-sidebar nav a:hover { color: var(--color-fg); background: var(--color-surface-2); text-decoration: none; }
267
+ .caos-workbench-sidebar nav a.active { color: var(--color-accent); border-color: color-mix(in oklab, var(--color-accent) 24%, transparent); background: var(--color-accent-soft); }
268
+ .caos-workbench-main { grid-column: 2; min-width: 0; }
269
+ .caos-workbench-header { position: sticky; top: 0; z-index: 15; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 2rem; border-bottom: 1px solid var(--color-border); background: color-mix(in oklab, var(--color-bg) 88%, transparent); backdrop-filter: blur(12px); }
270
+ .caos-workbench-content { width: min(1540px, 100%); margin: 0 auto; padding: 2.25rem 2rem 4rem; }
271
+
272
+ @media (max-width: 760px) {
273
+ .caos-workbench-shell { display: block; padding-bottom: 66px; }
274
+ .caos-workbench-sidebar { inset: auto 0 0; width: auto; height: 62px; display: block; border: 0; border-top: 1px solid var(--color-border); padding: 5px 8px; }
275
+ .caos-workbench-sidebar > :not(nav) { display: none; }
276
+ .caos-workbench-sidebar nav { grid-template-columns: repeat(var(--caos-workbench-route-count, 5), minmax(0, 1fr)); }
277
+ .caos-workbench-sidebar nav a { display: grid; justify-items: center; gap: 2px; overflow: hidden; padding: 5px; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
278
+ .caos-workbench-main { min-width: 0; }
279
+ .caos-workbench-header { min-height: 58px; padding: 0 1rem; }
280
+ .caos-workbench-content { padding: 1.5rem 1rem 2.5rem; }
281
+ }
282
+
283
+ /* CONTAINED LAYOUT. A `nowrap` tab bar sizes its container to its intrinsic width unless every
284
+ ancestor is allowed to shrink: a flex/grid item defaults to `min-width: auto`. Without this, making
285
+ the tab bar a single scrollable row pushes the whole page wider than the viewport and the user has to
286
+ drag sideways to reach the right edge. Measured on ChargeCascade: scrollWidth 1817 on a 1600 viewport. */
287
+ html, body { overflow-x: hidden; }
288
+ .page-body { max-width: 100%; }
289
+ .tabs, .tabpanel { min-width: 0; max-width: 100%; }
290
+ .tablist { min-width: 0; max-width: 100%; width: 100%; }
291
+
292
+ /* NO MAGIC CHROME NUMBER. An app surface must not compute its height from a hardcoded guess at the
293
+ header and footer: the guess drifts the moment either changes, leaving either a dead gap above the
294
+ footer or a few pixels of scroll. Measured on ChargeCascade: guess 150px, real 175px (header 57 +
295
+ footer 70 + 48px body padding), so 25px of scroll and a 48px gap. Apps that fill the viewport add
296
+ `.app-shell.fixed` and let flex distribute the height. */
297
+ .app-shell.fixed { height: 100dvh; min-height: 100dvh; overflow: hidden; }
298
+ .app-shell.fixed > .page { flex: 1 1 auto; min-height: 0; display: flex; }
299
+ .app-shell.fixed .page-body.wide { flex: 1 1 auto; min-height: 0; padding-top: 1rem; padding-bottom: 1rem; }
300
+
301
+ /* A viewport-filling app must not carry the prose footer margin: it becomes dead space between the
302
+ content and the chrome. Measured on ChargeCascade as exactly 48px (3rem). */
303
+ .app-shell.fixed .site-footer { margin-top: 0; }