@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 +16 -0
- package/README.md +51 -3
- package/index.css +1 -0
- package/nav.css +185 -0
- package/package.json +5 -3
- package/react/blocks.js +69 -0
- package/react/icons.js +35 -1
- package/react/index.js +7 -13
- package/react/labels.js +52 -0
- package/react/nav.js +457 -0
- package/tailwind-preset.js +2 -2
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,
|
|
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: `
|
|
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
|
-
| `
|
|
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
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
|
-
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header
|
|
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/",
|
package/react/blocks.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
};
|
package/tailwind-preset.js
CHANGED
|
@@ -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
|
-
|
|
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"),
|