@scalio-cloud/ui 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+ - **Visible change:** `rounded-sm` is now 2px as UISPEC says (was 0px from the preset). Buttons, fields, cards and
5
+ badges in Nexus, Citadel and Foundry get barely visible round corners; Rosetta already had 2px and can drop its
6
+ own `borderRadius` override.
7
+ - New app frame in `@scalio-cloud/ui/react`: `AppShell`, `Sidebar` (navigation as data: sections, hidden items,
8
+ badges, search button, one main action, version), `NavDrawer` for phones, `ContextSelect` for tenant, customer or
9
+ organisation in the header.
10
+ - Moved here from the apps: `ProductSwitcher` with `PRODUCTS`, `switcherHref`, `stageBadge`, `scalioFeatures`,
11
+ `useScalioApps`, and `SupportDrawer` (the app passes the send function). Same test ids as before.
12
+ - Page blocks: `KpiTile`, `EmptyState`, `Loading`, `Notice`, `StatusBadge`; `PageHeader` takes `back`.
13
+ - `LABELS` gets the shared navigation words (Übersicht, Verwaltung, Protokoll, Konten und Rollen, Einstellungen, ...).
14
+ - New `nav.css` (imported by `index.css`). No other visual change for apps that do not use the new parts.
15
+
16
+ ## 0.4.1
17
+ - No change to tokens, styles or components. CI and publish workflow on current action versions (past Node 20).
18
+
3
19
  ## 0.4.0
4
20
  - New sign-in building blocks in `@scalio-cloud/ui/react`: `AuthLayout` (app brand on a dark grid on the left, form
5
21
  on the right, language and optional theme top right, small brand above the form on phones), `AuthHeader`,
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @scalio-cloud/ui
2
2
 
3
- Shared look and feel for the Scalio apps (Nexus, Compass, Rosetta, Citadel, later Foundry and Pulse):
3
+ Shared look and feel for the Scalio apps (Nexus, Rosetta, Foundry, Citadel, Compass, later Pulse):
4
4
  the "Control Room" design tokens, a Tailwind preset and the fonts, served from the app itself.
5
- Spec: `spezifikation/UISPEC.md` in `Scalio-Cloud/scalio-rosetta`.
5
+ Spec: `design/UISPEC.md` in `Scalio-Cloud/scalio-docs` (single copy).
6
6
 
7
7
  | File | What it is |
8
8
  |---|---|
@@ -10,7 +10,8 @@ Spec: `spezifikation/UISPEC.md` in `Scalio-Cloud/scalio-rosetta`.
10
10
  | `fonts.css` | `@font-face` for Chivo, IBM Plex Sans, JetBrains Mono (woff2, latin, SIL OFL 1.1). No Google Fonts request |
11
11
  | `base.css` | body / heading / code font families |
12
12
  | `shell.css` | styles of the app header, user menu, page header and version (`scalio-*` classes) |
13
- | `index.css` | all four in one import |
13
+ | `nav.css` | sidebar, phone drawer, switcher, context select, support drawer, page blocks |
14
+ | `index.css` | all of them in one import |
14
15
  | `react/` | shared app header for React (see below), no dependency except React |
15
16
  | `tailwind-preset.js` | colors, fonts, radius, accordion animation, `brand` colors, `h-switcher`, `h-header`, `w-sidebar` |
16
17
  | `tokens.js` | raw values for code without CSS (charts, e-mails, PDFs) |
@@ -68,6 +69,53 @@ Texts are built in for `lang` `de` and `en` and can be overridden with `labels`.
68
69
  `TEST_IDS`. The components are plain CommonJS with `React.createElement`, so CRA, Vite and Jest need no extra config.
69
70
  Icons are inline (Lucide, ISC, see `licenses/`).
70
71
 
72
+ ## Sidebar, switcher and page blocks (React)
73
+
74
+ Every app has the same frame (UISPEC "Navigation"): switcher, header with the context select on the left, then
75
+ the sidebar with search, at most one main action, "Übersicht", up to three sections and "Verwaltung" last
76
+ (Protokoll, Konten und Rollen, Einstellungen). Profile and personal pages live only in the user menu. The app
77
+ describes its navigation as data; the sidebar, the phone drawer and (later) the search read the same list.
78
+
79
+ ```jsx
80
+ import { NavLink } from "react-router-dom";
81
+ import { AppShell, Sidebar, ProductSwitcher, ContextSelect, SupportDrawer, useScalioApps,
82
+ KpiTile, EmptyState, Loading, Notice, StatusBadge, LABELS } from "@scalio-cloud/ui/react";
83
+
84
+ const L = LABELS[lang];
85
+ const NAV = { sections: [
86
+ { id: "main", items: [{ to: "/", label: L.overview, icon: LayoutGrid, end: true }] },
87
+ { id: "compliance", label: t("Compliance"), items: [
88
+ { to: "/drifts", label: t("Drifts"), icon: GitCompareArrows, badge: open || null, badgeTone: "error" },
89
+ ] },
90
+ { id: "admin", label: L.administration, items: [
91
+ { to: "/audit", label: L.auditLog, icon: ScrollText },
92
+ { to: "/accounts", label: L.accounts, icon: Users, hidden: !isAdmin }, // hidden items and empty sections disappear
93
+ { to: "/settings", label: L.settings, icon: SlidersHorizontal },
94
+ ] },
95
+ ] };
96
+
97
+ const apps = useScalioApps(() => api.get("/apps").then((r) => r.data)); // once per page load
98
+ <AppShell
99
+ switcher={apps.features.switcher && <ProductSwitcher current="citadel" links={apps.links} stage={apps.stage} sso={sso} lang={lang} />}
100
+ header={<AppHeader ... onMenu={() => setDrawer(true)}
101
+ left={<ContextSelect label={L.tenant} value={tenantId} options={tenants} onChange={select} hint={domain} testId="tenant-switcher" />} />}
102
+ sidebar={<Sidebar nav={NAV} link={NavLink} lang={lang} version={VERSION} commit={COMMIT}
103
+ search={{ onOpen: openPalette, shortcut: "⌘K" }} primaryAction={{ label: t("Scan starten"), icon: Play, onSelect: scan }} />}
104
+ drawerOpen={drawer} onDrawerClose={() => setDrawer(false)} // phones: same sidebar in a drawer (test ids with "m-")
105
+ >
106
+ <Routes>...</Routes>
107
+ </AppShell>
108
+ <SupportDrawer open={supportOpen} onClose={...} lang={lang}
109
+ onSubmit={(d) => api.post("/support/tickets", { ...d, url: location.pathname }).then(() => toast.success(...))}
110
+ context={[["URL", location.pathname], [L.tenant, current?.name]]} />
111
+ ```
112
+
113
+ `link` is the router's `NavLink` (it gets `to`, `end`, a `className` function and `onClick`); without it the
114
+ sidebar renders plain links and marks the item for `activePath`. Item test ids default to `nav-<path>`
115
+ (`nav-overview` for `/`), or set `testId`. Page blocks: `KpiTile` (tone `error` = red number),
116
+ `EmptyState` (children = next step), `Loading`, `Notice` (`info`, `ok`, `warn`, `error`), `StatusBadge`
117
+ (tones `neutral`, `running`, `ok`, `warn`, `error`) and `PageHeader` with `back` for detail pages.
118
+
71
119
  ## Sign-in page and profile (React)
72
120
 
73
121
  All sign-in pages use the same frame and order: Microsoft first, then "oder mit E-Mail", the password field with
package/index.css CHANGED
@@ -4,3 +4,4 @@
4
4
  @import './base.css';
5
5
  @import './shell.css';
6
6
  @import './auth.css';
7
+ @import './nav.css';
package/nav.css ADDED
@@ -0,0 +1,185 @@
1
+ /* App frame (sidebar, drawer, switcher, context select, support drawer) and page blocks
2
+ * (KPI tile, empty state, notice, badge). Plain CSS on the tokens, classes start with "scalio-". */
3
+
4
+ /* Frame */
5
+ .scalio-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; min-height: 0; background: hsl(var(--background)); color: hsl(var(--foreground)); }
6
+ .scalio-shell-body { display: flex; flex: 1 1 auto; min-height: 0; }
7
+ .scalio-shell-main { flex: 1 1 auto; min-width: 0; overflow-y: auto; }
8
+
9
+ /* Sidebar */
10
+ .scalio-sidebar {
11
+ width: var(--scalio-sidebar-width, 240px); flex-shrink: 0; flex-direction: column; min-height: 0;
12
+ background: hsl(var(--muted)); border-right: 1px solid hsl(var(--border));
13
+ }
14
+ .scalio-sidebar-desktop { display: none; }
15
+ @media (min-width: 768px) { .scalio-sidebar-desktop { display: flex; } }
16
+ .scalio-sidebar-drawer { display: flex; width: 100%; height: 100%; border-right: 0; }
17
+ .scalio-sidebar-top { padding: 0.75rem 0.75rem 0; }
18
+ .scalio-sidebar-scroll { flex: 1 1 auto; overflow-y: auto; padding: 0.5rem 0 0.75rem; }
19
+ .scalio-nav-section + .scalio-nav-section { margin-top: 0.25rem; }
20
+ .scalio-nav-heading {
21
+ padding: 1.25rem 1.25rem 0.5rem; font-size: 11px; font-weight: 700; text-transform: uppercase;
22
+ letter-spacing: 0.12em; color: hsl(var(--muted-foreground));
23
+ }
24
+ .scalio-nav-heading.is-danger { color: hsl(var(--destructive)); }
25
+ .scalio-nav-heading.is-info { color: hsl(var(--primary)); }
26
+ .scalio-nav-item {
27
+ display: flex; align-items: center; gap: 0.75rem; height: 2.5rem; padding: 0 1.25rem;
28
+ border-left: 2px solid transparent; font-size: 0.875rem; color: hsl(var(--muted-foreground));
29
+ text-decoration: none; transition: color 150ms, background-color 150ms;
30
+ }
31
+ .scalio-nav-item:hover { color: hsl(var(--foreground)); background: hsl(var(--background) / 0.6); }
32
+ .scalio-nav-item.is-active { border-left-color: hsl(var(--primary)); background: hsl(var(--background)); color: hsl(var(--foreground)); font-weight: 600; }
33
+ .scalio-nav-item.is-bold { font-weight: 700; }
34
+ .scalio-nav-item:focus-visible, .scalio-nav-cta:focus-visible, .scalio-nav-search:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
35
+ .scalio-nav-icon { width: 1rem; height: 1rem; flex-shrink: 0; }
36
+ .scalio-nav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37
+ .scalio-nav-badge {
38
+ margin-left: auto; min-width: 1.25rem; padding: 0.0625rem 0.375rem; border-radius: 999px; text-align: center;
39
+ font-size: 10px; font-weight: 700; background: hsl(var(--primary) / 0.15); color: hsl(var(--primary));
40
+ }
41
+ .scalio-nav-badge.is-error { background: hsl(var(--destructive)); color: hsl(var(--destructive-foreground)); }
42
+ .scalio-nav-search {
43
+ display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
44
+ margin: 0.75rem 0.75rem 0; padding: 0.5rem 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px;
45
+ background: hsl(var(--background)); color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.875rem;
46
+ text-align: left; cursor: pointer;
47
+ }
48
+ .scalio-nav-search:hover { color: hsl(var(--foreground)); border-color: hsl(var(--input)); }
49
+ .scalio-nav-search-label { display: flex; align-items: center; gap: 0.5rem; }
50
+ .scalio-kbd {
51
+ padding: 0.0625rem 0.375rem; border: 1px solid hsl(var(--border)); border-radius: 2px;
52
+ font-family: 'JetBrains Mono', monospace; font-size: 10px; color: hsl(var(--muted-foreground));
53
+ }
54
+ .scalio-nav-cta {
55
+ display: flex; align-items: center; justify-content: center; gap: 0.5rem;
56
+ margin: 0.75rem 0.75rem 0; height: 2.25rem; padding: 0 0.75rem; border: 0; border-radius: 2px;
57
+ background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); text-decoration: none; cursor: pointer;
58
+ font: inherit; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
59
+ }
60
+ .scalio-nav-cta:hover { background: hsl(var(--primary) / 0.9); }
61
+ .scalio-sidebar-footer { border-top: 1px solid hsl(var(--border)); padding-top: 0.5rem; }
62
+ .scalio-sidebar-footer > :not(.scalio-version) { padding: 0 0.75rem 0.25rem; }
63
+
64
+ /* Drawer (phones) */
65
+ .scalio-drawer-root { position: fixed; inset: 0; z-index: 50; }
66
+ @media (min-width: 768px) { .scalio-drawer-root { display: none; } }
67
+ .scalio-drawer-backdrop { position: fixed; inset: 0; z-index: 40; background: rgb(9 9 11 / 0.4); }
68
+ .scalio-drawer {
69
+ position: fixed; inset: 0 auto 0 0; z-index: 50; width: min(18rem, 85vw); display: flex; flex-direction: column;
70
+ background: hsl(var(--muted)); border-right: 1px solid hsl(var(--border)); box-shadow: 0 8px 24px rgb(0 0 0 / 0.2);
71
+ padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); outline: none;
72
+ }
73
+ .scalio-drawer-head { display: flex; justify-content: flex-end; padding: 0.5rem 0.5rem 0; }
74
+ .scalio-drawer .scalio-sidebar { min-height: 0; flex: 1 1 auto; }
75
+
76
+ /* Product switcher */
77
+ .scalio-switcher {
78
+ height: var(--scalio-switcher-height, 32px); display: flex; align-items: center; gap: 0.25rem; padding: 0 0.75rem;
79
+ flex-shrink: 0; overflow-x: auto; background: #09090b; color: #a1a1aa; border-bottom: 1px solid #27272a;
80
+ font-family: 'JetBrains Mono', monospace; font-size: 11px;
81
+ }
82
+ .scalio-switcher-brand { margin-right: 0.75rem; font-family: 'Chivo', sans-serif; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em; color: #e4e4e7; }
83
+ .scalio-stage { margin-right: 0.75rem; padding: 0 0.375rem; border-radius: 2px; font-size: 10px; font-weight: 700; line-height: 1rem; letter-spacing: 0.1em; }
84
+ .scalio-stage-dev { background: rgb(14 165 233 / 0.15); color: #7dd3fc; box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4); }
85
+ .scalio-stage-test { background: rgb(245 158 11 / 0.15); color: #fcd34d; box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.4); }
86
+ .scalio-stage-demo { background: rgb(16 185 129 / 0.15); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.4); }
87
+ .scalio-switcher-item {
88
+ display: inline-flex; align-items: center; gap: 0.375rem; height: 1.5rem; padding: 0 0.625rem; border-radius: 2px;
89
+ text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; text-decoration: none; color: #71717a;
90
+ transition: color 150ms, background-color 150ms;
91
+ }
92
+ .scalio-switcher-item.is-link { color: #d4d4d8; }
93
+ .scalio-switcher-item.is-link:hover { color: #fff; background: #27272a; }
94
+ .scalio-switcher-item.is-active { background: #002FA7; color: #fff; }
95
+ .scalio-switcher-item.is-off, .scalio-switcher-item.is-soon { cursor: not-allowed; }
96
+ .scalio-switcher-item.is-soon { color: #52525b; }
97
+ .scalio-switcher-icon { width: 0.75rem; height: 0.75rem; }
98
+ .scalio-switcher-soon { margin-left: 0.125rem; padding: 0 0.25rem; border: 1px solid #3f3f46; border-radius: 2px; font-size: 9px; line-height: 14px; text-transform: none; letter-spacing: normal; color: #71717a; }
99
+
100
+ /* Context select (tenant, customer, organisation) in the header */
101
+ .scalio-context { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
102
+ .scalio-context-label { display: none; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
103
+ @media (min-width: 1024px) { .scalio-context-label { display: inline; } }
104
+ .scalio-context-field { position: relative; min-width: 0; }
105
+ .scalio-context-select {
106
+ appearance: none; -webkit-appearance: none; height: 2.25rem; max-width: 16rem; min-width: 8rem; padding: 0 2rem 0 0.75rem;
107
+ border: 1px solid hsl(var(--input)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
108
+ font: inherit; font-size: 0.875rem; font-weight: 500; text-overflow: ellipsis; cursor: pointer;
109
+ }
110
+ .scalio-context-select:hover { border-color: hsl(var(--primary)); }
111
+ .scalio-context-select:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
112
+ .scalio-context-chevron { position: absolute; right: 0.625rem; top: 50%; transform: translateY(-50%); pointer-events: none; color: hsl(var(--muted-foreground)); }
113
+ .scalio-context-hint { display: none; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
114
+ @media (min-width: 1280px) { .scalio-context-hint { display: inline; } }
115
+
116
+ /* Support drawer */
117
+ .scalio-support {
118
+ position: fixed; inset: 0 0 0 auto; z-index: 50; width: 100%; max-width: 24rem; overflow-y: auto; padding: 1.5rem;
119
+ background: hsl(var(--background)); color: hsl(var(--foreground)); border-left: 1px solid hsl(var(--border));
120
+ box-shadow: 0 8px 32px rgb(0 0 0 / 0.25); padding-top: calc(1.5rem + env(safe-area-inset-top, 0px));
121
+ }
122
+ .scalio-support-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; margin-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); }
123
+ .scalio-support-title { margin: 0; font-family: 'Chivo', sans-serif; font-weight: 700; font-size: 1.125rem; text-transform: uppercase; }
124
+ .scalio-support-context { margin-bottom: 1rem; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted)); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; overflow-wrap: anywhere; }
125
+ .scalio-support-form { display: flex; flex-direction: column; gap: 0.375rem; }
126
+ .scalio-field-label { margin-top: 0.5rem; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
127
+ .scalio-input {
128
+ width: 100%; height: 2.5rem; padding: 0 0.75rem; border: 1px solid hsl(var(--input)); border-radius: 2px;
129
+ background: hsl(var(--background)); color: hsl(var(--foreground)); font: inherit; font-size: 0.875rem; box-sizing: border-box;
130
+ }
131
+ .scalio-textarea { height: auto; padding: 0.5rem 0.75rem; resize: vertical; }
132
+ .scalio-input:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 0; }
133
+ .scalio-btn-primary {
134
+ margin-top: 0.75rem; height: 2.5rem; border: 0; border-radius: 2px; cursor: pointer;
135
+ background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
136
+ font: inherit; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
137
+ }
138
+ .scalio-btn-primary:hover { background: hsl(var(--primary) / 0.9); }
139
+ .scalio-btn-primary:disabled { opacity: 0.6; cursor: default; }
140
+
141
+ /* Page blocks */
142
+ .scalio-page-back { margin-bottom: 0.5rem; font-size: 0.875rem; }
143
+ .scalio-page-back a { color: hsl(var(--muted-foreground)); text-decoration: none; }
144
+ .scalio-page-back a:hover { color: hsl(var(--foreground)); }
145
+ .scalio-kpi {
146
+ display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem; text-align: left;
147
+ border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font: inherit;
148
+ }
149
+ .scalio-kpi.is-clickable { cursor: pointer; transition: border-color 150ms; }
150
+ .scalio-kpi.is-clickable:hover { border-color: hsl(var(--primary)); }
151
+ .scalio-kpi-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
152
+ .scalio-kpi-value { font-family: 'Chivo', sans-serif; font-weight: 900; font-size: 1.875rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
153
+ .scalio-kpi.is-error .scalio-kpi-value { color: hsl(var(--destructive)); }
154
+ .scalio-kpi.is-ok .scalio-kpi-value { color: #16a34a; }
155
+ .scalio-kpi.is-warn .scalio-kpi-value { color: #d97706; }
156
+ .scalio-kpi-hint { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: hsl(var(--muted-foreground)); }
157
+ .scalio-empty {
158
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; padding: 2.5rem 1rem;
159
+ border: 1px dashed hsl(var(--border)); border-radius: 2px; text-align: center; color: hsl(var(--muted-foreground));
160
+ background-image: linear-gradient(hsl(var(--border) / 0.5) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--border) / 0.5) 1px, transparent 1px);
161
+ background-size: 32px 32px;
162
+ }
163
+ .scalio-empty-text { margin: 0; padding: 0 0.75rem; background: hsl(var(--background)); font-size: 0.875rem; }
164
+ .scalio-loading { display: flex; align-items: center; gap: 0.5rem; padding: 1rem 0; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
165
+ .scalio-spinner { width: 0.875rem; height: 0.875rem; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 999px; animation: scalio-spin 0.8s linear infinite; }
166
+ @keyframes scalio-spin { to { transform: rotate(360deg); } }
167
+ @media (prefers-reduced-motion: reduce) { .scalio-spinner { animation: none; } }
168
+ .scalio-notice { padding: 0.75rem 1rem; border: 1px solid; border-left-width: 3px; border-radius: 2px; font-size: 0.875rem; }
169
+ .scalio-notice-title { font-weight: 600; margin-bottom: 0.125rem; }
170
+ .scalio-notice.is-info { border-color: hsl(var(--primary) / 0.35); background: hsl(var(--primary) / 0.06); }
171
+ .scalio-notice.is-ok { border-color: rgb(22 163 74 / 0.4); background: rgb(22 163 74 / 0.08); }
172
+ .scalio-notice.is-warn { border-color: rgb(217 119 6 / 0.45); background: rgb(217 119 6 / 0.1); }
173
+ .scalio-notice.is-error { border-color: hsl(var(--destructive) / 0.5); background: hsl(var(--destructive) / 0.08); color: hsl(var(--destructive)); }
174
+ .scalio-badge {
175
+ display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.0625rem 0.375rem; border: 1px solid; border-radius: 2px;
176
+ font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap;
177
+ }
178
+ .scalio-badge.is-neutral { color: hsl(var(--muted-foreground)); border-color: hsl(var(--border)); background: hsl(var(--muted)); }
179
+ .scalio-badge.is-running { color: hsl(var(--primary)); border-color: hsl(var(--primary) / 0.35); background: hsl(var(--primary) / 0.08); }
180
+ .scalio-badge.is-ok { color: #15803d; border-color: rgb(22 163 74 / 0.35); background: rgb(22 163 74 / 0.08); }
181
+ .scalio-badge.is-warn { color: #b45309; border-color: rgb(217 119 6 / 0.4); background: rgb(217 119 6 / 0.1); }
182
+ .scalio-badge.is-error { color: hsl(var(--destructive)); border-color: hsl(var(--destructive) / 0.35); background: hsl(var(--destructive) / 0.08); }
183
+ .dark .scalio-badge.is-running { color: #8ba6f7; border-color: rgb(139 166 247 / 0.4); }
184
+ .dark .scalio-badge.is-ok, .dark .scalio-kpi.is-ok .scalio-kpi-value { color: #4ade80; }
185
+ .dark .scalio-badge.is-warn, .dark .scalio-kpi.is-warn .scalio-kpi-value { color: #fbbf24; }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.4.0",
4
- "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header and sign-in layout",
3
+ "version": "0.5.0",
4
+ "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
7
7
  "repository": {
@@ -21,7 +21,8 @@
21
21
  "./package.json": "./package.json",
22
22
  "./react": "./react/index.js",
23
23
  "./shell.css": "./shell.css",
24
- "./auth.css": "./auth.css"
24
+ "./auth.css": "./auth.css",
25
+ "./nav.css": "./nav.css"
25
26
  },
26
27
  "files": [
27
28
  "tokens.js",
@@ -32,6 +33,7 @@
32
33
  "base.css",
33
34
  "shell.css",
34
35
  "auth.css",
36
+ "nav.css",
35
37
  "react/",
36
38
  "licenses/",
37
39
  "fonts/",
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ // Small page building blocks shared by every app: KPI tile, empty state, loading line, notice, status badge.
3
+ const React = require("react");
4
+ const h = React.createElement;
5
+ const { labelsFor } = require("./labels");
6
+
7
+ function cx() {
8
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
9
+ }
10
+
11
+ const TONES = ["neutral", "running", "ok", "warn", "error"];
12
+
13
+ function tone(t, fallback) {
14
+ return TONES.indexOf(t) >= 0 ? t : fallback;
15
+ }
16
+
17
+ // Number tile on overview pages. tone "error" makes the number red (needs attention).
18
+ function KpiTile({ label, value, hint, tone: t, onClick, testId }) {
19
+ const props = {
20
+ className: cx("scalio-kpi", onClick && "is-clickable", t && `is-${tone(t, "neutral")}`),
21
+ "data-testid": testId,
22
+ };
23
+ const body = [
24
+ h("div", { key: "l", className: "scalio-kpi-label" }, label),
25
+ h("div", { key: "v", className: "scalio-kpi-value" }, value),
26
+ hint ? h("div", { key: "h", className: "scalio-kpi-hint" }, hint) : null,
27
+ ];
28
+ if (onClick) return h("button", Object.assign({ type: "button", onClick }, props), body);
29
+ return h("div", props, body);
30
+ }
31
+
32
+ // Empty list or panel: what is missing and, as children, the next step (e.g. a button).
33
+ function EmptyState({ text, children, lang, labels, testId }) {
34
+ const L = labelsFor(lang, labels);
35
+ return h(
36
+ "div",
37
+ { className: "scalio-empty", "data-testid": testId || "empty-state" },
38
+ h("p", { className: "scalio-empty-text" }, text || L.empty),
39
+ children ? h("div", { className: "scalio-empty-action" }, children) : null
40
+ );
41
+ }
42
+
43
+ function Loading({ label, lang, labels, testId }) {
44
+ const L = labelsFor(lang, labels);
45
+ return h(
46
+ "div",
47
+ { className: "scalio-loading", role: "status", "data-testid": testId || "loading" },
48
+ h("span", { className: "scalio-spinner", "aria-hidden": "true" }),
49
+ label || L.loading
50
+ );
51
+ }
52
+
53
+ // kind: info, ok, warn, error.
54
+ function Notice({ kind, title, children, testId }) {
55
+ const k = ["info", "ok", "warn", "error"].indexOf(kind) >= 0 ? kind : "info";
56
+ return h(
57
+ "div",
58
+ { className: cx("scalio-notice", `is-${k}`), role: k === "error" ? "alert" : "status", "data-testid": testId },
59
+ title ? h("div", { className: "scalio-notice-title" }, title) : null,
60
+ children
61
+ );
62
+ }
63
+
64
+ // Fixed tones so "läuft", "fertig", "Fehler" look the same in every app. The app maps its own states.
65
+ function StatusBadge({ tone: t, children, testId }) {
66
+ return h("span", { className: cx("scalio-badge", `is-${tone(t, "neutral")}`), "data-testid": testId }, children);
67
+ }
68
+
69
+ module.exports = { KpiTile, EmptyState, Loading, Notice, StatusBadge, TONES };
package/react/icons.js CHANGED
@@ -36,4 +36,38 @@ const LogOut = icon([["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 1
36
36
  const Menu = icon([["path", { d: "M4 6h16M4 12h16M4 18h16" }]]);
37
37
  const Check = icon([["path", { d: "M20 6 9 17l-5-5" }]]);
38
38
 
39
- module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check };
39
+ const X = icon([["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]]);
40
+ const Search = icon([["circle", { cx: 11, cy: 11, r: 8 }], ["path", { d: "m21 21-4.3-4.3" }]]);
41
+ const ChevronDown = icon([["path", { d: "m6 9 6 6 6-6" }]]);
42
+ // Product marks in the switcher.
43
+ const Fingerprint = icon([
44
+ ["path", { d: "M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4" }],
45
+ ["path", { d: "M14 13.12c0 2.38 0 6.38-1 8.88" }],
46
+ ["path", { d: "M17.29 21.02c.12-.6.43-2.3.5-3.02" }],
47
+ ["path", { d: "M2 12a10 10 0 0 1 18-6" }],
48
+ ["path", { d: "M2 16h.01" }],
49
+ ["path", { d: "M21.8 16c.2-2 .131-5.354 0-6" }],
50
+ ["path", { d: "M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2" }],
51
+ ["path", { d: "M8.65 22c.21-.66.45-1.32.57-2" }],
52
+ ["path", { d: "M9 6.8a6 6 0 0 1 9 5.2v2" }],
53
+ ]);
54
+ const FileStack = icon([
55
+ ["path", { d: "M21 7h-3a2 2 0 0 1-2-2V2" }],
56
+ ["path", { d: "M21 6v6.5c0 .8-.7 1.5-1.5 1.5h-7c-.8 0-1.5-.7-1.5-1.5v-9c0-.8.7-1.5 1.5-1.5H17Z" }],
57
+ ["path", { d: "M7 8v8.8c0 .3.2.6.4.8.2.2.5.4.8.4H15" }],
58
+ ["path", { d: "M3 12v8.8c0 .3.2.6.4.8.2.2.5.4.8.4H11" }],
59
+ ]);
60
+ const ShieldCheck = icon([
61
+ ["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }],
62
+ ["path", { d: "m9 12 2 2 4-4" }],
63
+ ]);
64
+ const Hammer = icon([
65
+ ["path", { d: "m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9" }],
66
+ ["path", { d: "m18 15 4-4" }],
67
+ ["path", { d: "m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5" }],
68
+ ]);
69
+ const Activity = icon([
70
+ ["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }],
71
+ ]);
72
+
73
+ module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity };
package/react/index.js CHANGED
@@ -298,7 +298,8 @@ function AppHeader(props) {
298
298
  );
299
299
  }
300
300
 
301
- function PageHeader({ eyebrow, title, subtitle, actions, children, testId }) {
301
+ // back: a link above the eyebrow on detail pages ("‹ Alle Drifts").
302
+ function PageHeader({ eyebrow, title, subtitle, actions, children, back, testId }) {
302
303
  const right = actions || children;
303
304
  return h(
304
305
  "div",
@@ -306,6 +307,7 @@ function PageHeader({ eyebrow, title, subtitle, actions, children, testId }) {
306
307
  h(
307
308
  "div",
308
309
  { className: "scalio-page-header-text" },
310
+ back ? h("div", { className: "scalio-page-back" }, back) : null,
309
311
  eyebrow ? h("div", { className: "scalio-eyebrow" }, eyebrow) : null,
310
312
  h("h1", { className: "scalio-page-title" }, title),
311
313
  subtitle ? h("p", { className: "scalio-page-subtitle" }, subtitle) : null
@@ -314,18 +316,9 @@ function PageHeader({ eyebrow, title, subtitle, actions, children, testId }) {
314
316
  );
315
317
  }
316
318
 
317
- // "Version 1.2.4 · 3f2a1bc" at the bottom of the sidebar.
318
- function VersionTag({ version, commit, lang, labels, testId }) {
319
- if (!version) return null;
320
- const L = labelsFor(lang, labels);
321
- const short = commit ? String(commit).slice(0, 7) : "";
322
- return h(
323
- "div",
324
- { className: "scalio-version", "data-testid": testId || "app-version", title: commit || undefined },
325
- `${L.version} ${version}`,
326
- short ? h("span", { className: "scalio-version-commit" }, ` · ${short}`) : null
327
- );
328
- }
319
+ const Nav = require("./nav");
320
+ const Blocks = require("./blocks");
321
+ const VersionTag = Nav.VersionTag;
329
322
 
330
323
  module.exports = {
331
324
  AppHeader,
@@ -340,5 +333,6 @@ module.exports = {
340
333
  TEST_IDS,
341
334
  initials,
342
335
  };
336
+ Object.assign(module.exports, Nav, Blocks);
343
337
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
344
338
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -29,6 +29,34 @@ const LABELS = {
29
29
  confirmPassword: "Neues Passwort bestätigen",
30
30
  twoFactor: "Zwei-Faktor-Anmeldung",
31
31
  mfaCode: "Code aus der Authenticator-App",
32
+ // Navigation (same words in every app, see the glossary in UISPEC)
33
+ overview: "Übersicht",
34
+ administration: "Verwaltung",
35
+ auditLog: "Protokoll",
36
+ accounts: "Konten und Rollen",
37
+ settings: "Einstellungen",
38
+ search: "Suchen",
39
+ navigation: "Navigation",
40
+ closeMenu: "Menü schliessen",
41
+ tenant: "Tenant",
42
+ customer: "Kunde",
43
+ // Switcher
44
+ soon: "bald",
45
+ switchTo: "Zu dieser Anwendung wechseln",
46
+ activeApp: "Aktive Anwendung",
47
+ comingSoon: "Bald verfügbar",
48
+ notSetUp: "Für diese Installation nicht eingerichtet",
49
+ stageHint: "Umgebung dieser Installation",
50
+ // Support request
51
+ supportTitle: "Support-Anfrage",
52
+ subject: "Betreff",
53
+ description: "Beschreibung",
54
+ send: "Anfrage senden",
55
+ sending: "Wird gesendet …",
56
+ close: "Schliessen",
57
+ // States
58
+ loading: "Wird geladen …",
59
+ empty: "Noch keine Einträge.",
32
60
  sections: {
33
61
  account: "Konto",
34
62
  appearance: "Darstellung",
@@ -71,6 +99,30 @@ const LABELS = {
71
99
  confirmPassword: "Confirm new password",
72
100
  twoFactor: "Two-factor sign-in",
73
101
  mfaCode: "Code from the authenticator app",
102
+ overview: "Overview",
103
+ administration: "Administration",
104
+ auditLog: "Activity log",
105
+ accounts: "Accounts and roles",
106
+ settings: "Settings",
107
+ search: "Search",
108
+ navigation: "Navigation",
109
+ closeMenu: "Close menu",
110
+ tenant: "Tenant",
111
+ customer: "Customer",
112
+ soon: "soon",
113
+ switchTo: "Switch to this application",
114
+ activeApp: "Active application",
115
+ comingSoon: "Coming soon",
116
+ notSetUp: "Not set up for this installation",
117
+ stageHint: "Environment of this installation",
118
+ supportTitle: "Support request",
119
+ subject: "Subject",
120
+ description: "Description",
121
+ send: "Send request",
122
+ sending: "Sending …",
123
+ close: "Close",
124
+ loading: "Loading …",
125
+ empty: "No entries yet.",
74
126
  sections: {
75
127
  account: "Account",
76
128
  appearance: "Appearance",
package/react/nav.js ADDED
@@ -0,0 +1,457 @@
1
+ "use strict";
2
+ // App frame below the header: sidebar from navigation data, drawer for phones, context select,
3
+ // product switcher and support drawer. Same rules in every app (UISPEC, "Navigation"):
4
+ // search, one main action, Übersicht, up to three sections, "Verwaltung" last, version at the bottom.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+ const { labelsFor } = require("./labels");
9
+
10
+ function cx() {
11
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
12
+ }
13
+
14
+ function slug(s) {
15
+ return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
16
+ }
17
+
18
+ // Same rule as react-router's NavLink: "/" (or end: true) matches only exactly, others also match below.
19
+ function isActivePath(path, to, end) {
20
+ const p = String(path || "/").replace(/\/+$/, "") || "/";
21
+ const t = String(to || "/").replace(/\/+$/, "") || "/";
22
+ if (end || t === "/") return p === t;
23
+ return p === t || p.startsWith(t + "/");
24
+ }
25
+
26
+ // Sections without visible items disappear; items with hidden: true too. Apps set hidden from their
27
+ // permissions and licence, so the sidebar, the drawer and the search all see the same list.
28
+ function visibleSections(nav) {
29
+ const sections = (nav && nav.sections) || [];
30
+ return sections
31
+ .map((s) => Object.assign({}, s, { items: (s.items || []).filter((i) => i && !i.hidden) }))
32
+ .filter((s) => s.items.length > 0);
33
+ }
34
+
35
+ function itemTestId(item, prefix) {
36
+ return (prefix || "") + (item.testId || `nav-${item.id || slug(item.to === "/" ? "overview" : item.to)}`);
37
+ }
38
+
39
+ function NavItem({ item, link, activePath, onNavigate, testIdPrefix }) {
40
+ const Icon = item.icon;
41
+ const cls = (active) => cx("scalio-nav-item", active && "is-active", item.bold && "is-bold");
42
+ const content = [
43
+ Icon ? h(Icon, { key: "i", className: "scalio-nav-icon", strokeWidth: item.bold ? 2.5 : 2, "aria-hidden": "true" }) : null,
44
+ h("span", { key: "l", className: "scalio-nav-label" }, item.label),
45
+ item.badge != null && item.badge !== false
46
+ ? h("span", { key: "b", className: cx("scalio-nav-badge", item.badgeTone && `is-${item.badgeTone}`), "data-testid": item.badgeTestId }, item.badge)
47
+ : null,
48
+ ];
49
+ const testId = itemTestId(item, testIdPrefix);
50
+ if (link) {
51
+ return h(link, { to: item.to, end: item.end, className: ({ isActive }) => cls(isActive), onClick: onNavigate, "data-testid": testId }, content);
52
+ }
53
+ const active = isActivePath(activePath, item.to, item.end);
54
+ return h("a", { href: item.to, className: cls(active), "aria-current": active ? "page" : undefined, onClick: onNavigate, "data-testid": testId }, content);
55
+ }
56
+
57
+ function PrimaryAction({ action, link, onNavigate, testIdPrefix }) {
58
+ if (!action) return null;
59
+ const Icon = action.icon;
60
+ const testId = (testIdPrefix || "") + (action.testId || "nav-primary-action");
61
+ const content = [Icon ? h(Icon, { key: "i", className: "scalio-icon", "aria-hidden": "true" }) : null, h("span", { key: "l" }, action.label)];
62
+ if (action.to && link) {
63
+ return h(link, { to: action.to, className: "scalio-nav-cta", onClick: onNavigate, "data-testid": testId }, content);
64
+ }
65
+ if (action.to) return h("a", { href: action.to, className: "scalio-nav-cta", onClick: onNavigate, "data-testid": testId }, content);
66
+ return h(
67
+ "button",
68
+ {
69
+ type: "button",
70
+ className: "scalio-nav-cta",
71
+ "data-testid": testId,
72
+ onClick: () => {
73
+ if (onNavigate) onNavigate();
74
+ if (action.onSelect) action.onSelect();
75
+ },
76
+ },
77
+ content
78
+ );
79
+ }
80
+
81
+ function SearchButton({ search, lang, labels, testIdPrefix, onNavigate }) {
82
+ if (!search) return null;
83
+ const L = labelsFor(lang, labels);
84
+ return h(
85
+ "button",
86
+ {
87
+ type: "button",
88
+ className: "scalio-nav-search",
89
+ "data-testid": (testIdPrefix || "") + (search.testId || "command-palette-trigger"),
90
+ onClick: () => {
91
+ if (onNavigate) onNavigate();
92
+ if (search.onOpen) search.onOpen();
93
+ },
94
+ },
95
+ h("span", { className: "scalio-nav-search-label" }, h(Icons.Search, { className: "scalio-icon" }), search.label || L.search),
96
+ search.shortcut ? h("kbd", { className: "scalio-kbd" }, search.shortcut) : null
97
+ );
98
+ }
99
+
100
+ // Sidebar. variant "desktop" shows from 768px, "drawer" is the same content inside NavDrawer.
101
+ // link: the router's NavLink (gets to, end, className(fn), onClick); without it plain <a> + activePath.
102
+ function Sidebar(props) {
103
+ const {
104
+ nav, link, activePath, top, search, primaryAction, footer, version, commit, lang, labels,
105
+ variant, onNavigate, testId, testIdPrefix,
106
+ } = props;
107
+ const prefix = testIdPrefix != null ? testIdPrefix : variant === "drawer" ? "m-" : "";
108
+ const L = labelsFor(lang, labels);
109
+ const sections = visibleSections(nav);
110
+ return h(
111
+ "nav",
112
+ {
113
+ className: cx("scalio-sidebar", variant === "drawer" ? "scalio-sidebar-drawer" : "scalio-sidebar-desktop"),
114
+ "aria-label": L.navigation,
115
+ "data-testid": testId || `${prefix}sidebar-nav`,
116
+ },
117
+ top ? h("div", { className: "scalio-sidebar-top" }, top) : null,
118
+ h(SearchButton, { search, lang, labels, testIdPrefix: prefix, onNavigate }),
119
+ h(PrimaryAction, { action: primaryAction, link, onNavigate, testIdPrefix: prefix }),
120
+ h(
121
+ "div",
122
+ { className: "scalio-sidebar-scroll" },
123
+ sections.map((sec, i) =>
124
+ h(
125
+ "div",
126
+ { key: sec.id || i, className: "scalio-nav-section", "data-section": sec.id },
127
+ sec.label ? h("div", { className: cx("scalio-nav-heading", sec.tone && `is-${sec.tone}`) }, sec.label) : null,
128
+ sec.items.map((item) => h(NavItem, { key: item.to, item, link, activePath, onNavigate, testIdPrefix: prefix }))
129
+ )
130
+ )
131
+ ),
132
+ footer || version
133
+ ? h(
134
+ "div",
135
+ { className: "scalio-sidebar-footer" },
136
+ footer || null,
137
+ version ? h(VersionTag, { version, commit, lang, labels, testId: `${prefix}app-version` }) : null
138
+ )
139
+ : null
140
+ );
141
+ }
142
+
143
+ // "Version 1.2.4 · 3f2a1bc" at the bottom of the sidebar.
144
+ function VersionTag({ version, commit, lang, labels, testId }) {
145
+ const L = labelsFor(lang, labels);
146
+ const short = commit ? String(commit).slice(0, 7) : "";
147
+ if (!version) return null;
148
+ return h(
149
+ "div",
150
+ { className: "scalio-version", "data-testid": testId || "app-version", title: commit || undefined },
151
+ `${L.version} ${version}`,
152
+ short ? h("span", { className: "scalio-version-commit" }, ` · ${short}`) : null
153
+ );
154
+ }
155
+
156
+ // Panel from the left on phones. Escape and a click on the backdrop close it.
157
+ function NavDrawer({ open, onClose, children, lang, labels, testId }) {
158
+ const L = labelsFor(lang, labels);
159
+ const ref = React.useRef(null);
160
+ React.useEffect(() => {
161
+ if (!open) return undefined;
162
+ if (ref.current) ref.current.focus();
163
+ function onKey(e) {
164
+ if (e.key === "Escape" && onClose) onClose();
165
+ }
166
+ document.addEventListener("keydown", onKey);
167
+ return () => document.removeEventListener("keydown", onKey);
168
+ }, [open, onClose]);
169
+ if (!open) return null;
170
+ return h(
171
+ "div",
172
+ { className: "scalio-drawer-root", "data-testid": testId || "mobile-nav" },
173
+ h("div", { className: "scalio-drawer-backdrop", onClick: onClose, "data-testid": "mobile-nav-backdrop" }),
174
+ h(
175
+ "div",
176
+ { className: "scalio-drawer", role: "dialog", "aria-modal": "true", "aria-label": L.navigation, tabIndex: -1, ref },
177
+ h(
178
+ "div",
179
+ { className: "scalio-drawer-head" },
180
+ h(
181
+ "button",
182
+ { type: "button", className: "scalio-btn scalio-btn-icon", "aria-label": L.closeMenu, onClick: onClose, "data-testid": "mobile-nav-close" },
183
+ h(Icons.X, { className: "scalio-icon" })
184
+ )
185
+ ),
186
+ children
187
+ )
188
+ );
189
+ }
190
+
191
+ // Whole frame: switcher, header, banners, then sidebar + content. The same sidebar element is shown
192
+ // inside the drawer on phones (variant "drawer", closes on navigation).
193
+ function AppShell({ switcher, header, banner, sidebar, children, drawerOpen, onDrawerClose, lang, labels, testId }) {
194
+ return h(
195
+ "div",
196
+ { className: "scalio-shell", "data-testid": testId || "app-shell" },
197
+ switcher || null,
198
+ header || null,
199
+ banner || null,
200
+ h(
201
+ "div",
202
+ { className: "scalio-shell-body" },
203
+ sidebar || null,
204
+ h("main", { className: "scalio-shell-main", "data-testid": "app-main" }, children)
205
+ ),
206
+ sidebar
207
+ ? h(
208
+ NavDrawer,
209
+ { open: !!drawerOpen, onClose: onDrawerClose, lang, labels },
210
+ React.cloneElement(sidebar, { variant: "drawer", onNavigate: onDrawerClose })
211
+ )
212
+ : null
213
+ );
214
+ }
215
+
216
+ // Tenant, customer or organisation: always left in the header (after the brand).
217
+ function ContextSelect({ label, value, options, onChange, hint, after, testId, id }) {
218
+ const selectId = id || `scalio-context-${slug(label) || "select"}`;
219
+ return h(
220
+ "div",
221
+ { className: "scalio-context" },
222
+ label ? h("label", { className: "scalio-context-label", htmlFor: selectId }, label) : null,
223
+ h(
224
+ "div",
225
+ { className: "scalio-context-field" },
226
+ h(
227
+ "select",
228
+ {
229
+ id: selectId,
230
+ className: "scalio-context-select",
231
+ value: value == null ? "" : value,
232
+ onChange: (e) => onChange && onChange(e.target.value),
233
+ "data-testid": testId || "context-select",
234
+ },
235
+ (options || []).map((o) => h("option", { key: o.value, value: o.value }, o.label))
236
+ ),
237
+ h(Icons.ChevronDown, { className: "scalio-icon scalio-context-chevron" })
238
+ ),
239
+ hint ? h("span", { className: "scalio-context-hint", "data-testid": `${testId || "context-select"}-hint` }, hint) : null,
240
+ after || null
241
+ );
242
+ }
243
+
244
+ // --- Product switcher -------------------------------------------------------------------------
245
+ // Display order of the app group. Compass is not part of it (decided 2026-10-26).
246
+ const PRODUCTS = [
247
+ { key: "nexus", name: "Nexus", icon: Icons.Fingerprint },
248
+ { key: "rosetta", name: "Rosetta", icon: Icons.FileStack },
249
+ { key: "foundry", name: "Foundry", icon: Icons.Hammer },
250
+ { key: "citadel", name: "Citadel", icon: Icons.ShieldCheck },
251
+ { key: "pulse", name: "Pulse", icon: Icons.Activity, soon: true },
252
+ ];
253
+
254
+ function productStatus(product, current) {
255
+ if (product.key === current) return "active";
256
+ return product.soon ? "soon" : "available";
257
+ }
258
+
259
+ // Link of a switcher entry: only available apps with a valid link for this installation
260
+ // (GET /api/apps, SCALIO_APP_LINKS). sso: signed in through Scalio ID, the other app signs in silently.
261
+ function switcherHref(product, links, sso, current) {
262
+ if (productStatus(product, current) !== "available") return null;
263
+ const url = links && links[product.key];
264
+ if (typeof url !== "string" || !/^https?:\/\/[^\s]+$/.test(url)) return null;
265
+ if (!sso) return url;
266
+ try {
267
+ const u = new URL(url);
268
+ u.searchParams.set("sso", "1");
269
+ return u.toString();
270
+ } catch (e) {
271
+ return url;
272
+ }
273
+ }
274
+
275
+ const STAGES = { dev: "DEV", test: "TEST", demo: "DEMO" };
276
+
277
+ function stageBadge(stage) {
278
+ return Object.prototype.hasOwnProperty.call(STAGES, stage) ? STAGES[stage] : null;
279
+ }
280
+
281
+ // Missing counts as off: a self-hosted customer (or an older backend) sees neither switcher nor support.
282
+ function scalioFeatures(answer) {
283
+ const f = (answer && answer.features) || {};
284
+ return { switcher: f.switcher === true, support: f.support === true };
285
+ }
286
+
287
+ function ProductSwitcher({ current, links, stage, sso, lang, labels, products, testId }) {
288
+ const L = labelsFor(lang, labels);
289
+ const badge = stageBadge(stage);
290
+ return h(
291
+ "div",
292
+ { className: "scalio-switcher", "data-testid": testId || "scalio-product-switcher" },
293
+ h("span", { className: "scalio-switcher-brand" }, "Scalio"),
294
+ badge
295
+ ? h("span", { className: `scalio-stage scalio-stage-${stage}`, title: L.stageHint, "data-testid": "scalio-stage-badge", "data-stage": stage }, badge)
296
+ : null,
297
+ (products || PRODUCTS).map((product) => {
298
+ const status = productStatus(product, current);
299
+ const Icon = product.icon;
300
+ const href = switcherHref(product, links, sso, current);
301
+ const common = { key: product.key, "data-testid": `product-switch-${product.key}`, "data-status": status };
302
+ const content = [
303
+ Icon ? h(Icon, { key: "i", className: "scalio-switcher-icon" }) : null,
304
+ h("span", { key: "n" }, product.name),
305
+ status === "soon" ? h("span", { key: "s", className: "scalio-switcher-soon", "data-testid": `product-soon-${product.key}` }, L.soon) : null,
306
+ ];
307
+ if (href) {
308
+ return h("a", Object.assign({ href, title: L.switchTo, className: "scalio-switcher-item is-link" }, common), content);
309
+ }
310
+ const title = status === "active" ? L.activeApp : status === "soon" ? L.comingSoon : L.notSetUp;
311
+ return h(
312
+ "span",
313
+ Object.assign(
314
+ {
315
+ title,
316
+ "aria-current": status === "active" ? "page" : undefined,
317
+ "aria-disabled": status === "active" ? undefined : "true",
318
+ className: cx("scalio-switcher-item", `is-${status === "available" ? "off" : status}`),
319
+ },
320
+ common
321
+ ),
322
+ content
323
+ );
324
+ })
325
+ );
326
+ }
327
+
328
+ // GET /api/apps once per page load. fetchApps returns a promise with the JSON answer.
329
+ // Until the answer is in (or on error) switcher and support stay hidden.
330
+ let appsCache = null;
331
+ let appsPending = null;
332
+
333
+ function useScalioApps(fetchApps) {
334
+ const [data, setData] = React.useState(appsCache);
335
+ React.useEffect(() => {
336
+ if (appsCache || !fetchApps) return undefined;
337
+ let alive = true;
338
+ appsPending =
339
+ appsPending ||
340
+ Promise.resolve()
341
+ .then(fetchApps)
342
+ .then((d) => {
343
+ appsCache = d || {};
344
+ return appsCache;
345
+ });
346
+ appsPending.then((d) => alive && setData(d)).catch(() => { appsPending = null; });
347
+ return () => { alive = false; };
348
+ }, [fetchApps]);
349
+ return {
350
+ links: (data && data.links) || {},
351
+ stage: (data && data.stage) || "prod",
352
+ saas: !!(data && data.saas),
353
+ features: scalioFeatures(data),
354
+ };
355
+ }
356
+
357
+ function resetScalioApps() {
358
+ appsCache = null;
359
+ appsPending = null;
360
+ }
361
+
362
+ // --- Support drawer ---------------------------------------------------------------------------
363
+ // onSubmit({ subject, description }) sends the request (and shows the app's toast); a thrown error is
364
+ // shown in the drawer. context: rows like [["URL", "/drifts"], ["Tenant", "Contoso"]].
365
+ function SupportDrawer({ open, onClose, onSubmit, context, lang, labels }) {
366
+ const L = labelsFor(lang, labels);
367
+ const [subject, setSubject] = React.useState("");
368
+ const [description, setDescription] = React.useState("");
369
+ const [busy, setBusy] = React.useState(false);
370
+ const [error, setError] = React.useState("");
371
+ React.useEffect(() => {
372
+ if (!open) return undefined;
373
+ function onKey(e) {
374
+ if (e.key === "Escape" && onClose) onClose();
375
+ }
376
+ document.addEventListener("keydown", onKey);
377
+ return () => document.removeEventListener("keydown", onKey);
378
+ }, [open, onClose]);
379
+ if (!open) return null;
380
+ const submit = async (e) => {
381
+ e.preventDefault();
382
+ setBusy(true);
383
+ setError("");
384
+ try {
385
+ await onSubmit({ subject, description });
386
+ setSubject("");
387
+ setDescription("");
388
+ if (onClose) onClose();
389
+ } catch (err) {
390
+ setError((err && err.message) || String(err));
391
+ } finally {
392
+ setBusy(false);
393
+ }
394
+ };
395
+ return h(
396
+ React.Fragment,
397
+ null,
398
+ h("div", { className: "scalio-drawer-backdrop", onClick: onClose }),
399
+ h(
400
+ "div",
401
+ { className: "scalio-support", role: "dialog", "aria-modal": "true", "aria-label": L.supportTitle, "data-testid": "scalio-support-drawer" },
402
+ h(
403
+ "div",
404
+ { className: "scalio-support-head" },
405
+ h("h3", { className: "scalio-support-title" }, L.supportTitle),
406
+ h(
407
+ "button",
408
+ { type: "button", className: "scalio-btn scalio-btn-icon", onClick: onClose, "aria-label": L.close, "data-testid": "support-drawer-close" },
409
+ h(Icons.X, { className: "scalio-icon" })
410
+ )
411
+ ),
412
+ context && context.length
413
+ ? h(
414
+ "div",
415
+ { className: "scalio-support-context", "data-testid": "support-context" },
416
+ context.map((row, i) => h("div", { key: i }, `${row[0]}: ${row[1] == null || row[1] === "" ? "—" : row[1]}`))
417
+ )
418
+ : null,
419
+ h(
420
+ "form",
421
+ { className: "scalio-support-form", onSubmit: submit },
422
+ h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
423
+ h("input", {
424
+ id: "support-subject", className: "scalio-input", value: subject, required: true, minLength: 3, maxLength: 200,
425
+ onChange: (e) => setSubject(e.target.value), "data-testid": "ticket-subject-input",
426
+ }),
427
+ h("label", { className: "scalio-field-label", htmlFor: "support-description" }, L.description),
428
+ h("textarea", {
429
+ id: "support-description", className: "scalio-input scalio-textarea", rows: 5, value: description, required: true,
430
+ minLength: 3, maxLength: 5000, onChange: (e) => setDescription(e.target.value), "data-testid": "ticket-description-input",
431
+ }),
432
+ error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "support-error" }, error) : null,
433
+ h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "ticket-submit-btn" }, busy ? L.sending : L.send)
434
+ )
435
+ )
436
+ );
437
+ }
438
+
439
+ module.exports = {
440
+ VersionTag,
441
+ Sidebar,
442
+ NavItem,
443
+ NavDrawer,
444
+ AppShell,
445
+ ContextSelect,
446
+ ProductSwitcher,
447
+ PRODUCTS,
448
+ productStatus,
449
+ switcherHref,
450
+ stageBadge,
451
+ scalioFeatures,
452
+ useScalioApps,
453
+ resetScalioApps,
454
+ SupportDrawer,
455
+ visibleSections,
456
+ isActivePath,
457
+ };
@@ -16,9 +16,9 @@ module.exports = {
16
16
  fontFamily: { ...fonts },
17
17
  borderRadius: {
18
18
  lg: "var(--radius)",
19
- // Same steps as shadcn/ui and today's apps, so existing classes keep their look.
20
19
  md: "calc(var(--radius) - 2px)",
21
- sm: "calc(var(--radius) - 4px)",
20
+ // UISPEC: sharp 2px corners on buttons, fields, cards and badges (was 0px before 0.5.0).
21
+ sm: "2px",
22
22
  },
23
23
  colors: {
24
24
  background: v("background"),