@scalio-cloud/ui
Shared look and feel for the Scalio apps (Nexus, Rosetta, Foundry, Citadel, Compass, later Pulse):
the "Control Room" design tokens, a Tailwind preset and the fonts, served from the app itself.
Spec: design/UISPEC.md in Scalio-Cloud/scalio-docs (single copy).
| File | What it is |
|---|---|
tokens.css | shadcn/ui CSS variables, light on :root, dark on .dark (Klein Blue #002FA7, radius 4px) |
fonts.css | @font-face for Chivo, IBM Plex Sans, JetBrains Mono (woff2, latin, SIL OFL 1.1). No Google Fonts request |
base.css | body / heading / code font families |
shell.css | styles of the app header, user menu, page header and version (scalio-* classes) |
nav.css | sidebar, phone drawer, switcher, context select, support drawer, page blocks |
index.css | all of them in one import |
react/ | shared app header for React (see below), no dependency except React |
tailwind-preset.js | colors, fonts, radius, accordion animation, brand colors, h-switcher, h-header, w-sidebar |
tokens.js | raw values for code without CSS (charts, e-mails, PDFs) |
Use it in an app (CRA/craco or Vite)
// tailwind.config.js
module.exports = {
presets: [require("@scalio-cloud/ui/tailwind-preset")],
content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
plugins: [require("tailwindcss-animate")],
};
/* src/index.css, first line */
@import "@scalio-cloud/ui/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
Then remove the app's own :root { --... } / .dark { --... } blocks and the Google Fonts @import / <link>.
Classes like bg-primary, rounded-sm, font-display keep working unchanged.
App header (React)
Every app shows the same header on the right, always in this order: (app actions), status, language, theme,
support, avatar.
A part only appears when the app passes its handler (Compass has no dark mode, so no onThemeChange).
import { AppHeader, PageHeader, VersionTag, manualUrl } from "@scalio-cloud/ui/react";
<AppHeader
brand={{ mark: "N", name: "Scalio Nexus", sub: "Dev" }} // or your own element
onMenu={() => setMobileNavOpen(true)} // hamburger below 768px
left={<TenantSelect />} // after the brand
actions={<LocalePopover />} // right side, before the fixed group
status={<ApiStatus />} // hidden below 768px
lang={lang} onLangChange={setLang} // one click, shows the other language
theme={theme} themeModes={["light", "dark", "system"]} onThemeChange={setTheme}
docsUrl={manualUrl("nexus", { stage, lang })} // book icon, opens the manual in a new tab
onSupport={() => setSupportOpen(true)}
user={{ name, email, role }} onLogout={logout}
menuItems={[{ label: "Profil", icon: User, onSelect: () => nav("/profile"), testId: "profile-link" }]}
testIds={{ logout: "logout-button" }} // keep an app's existing test ids
/>
<PageHeader eyebrow="Verwaltung" title="Benutzer" subtitle="..." actions={<Button>Neu</Button>} />
<VersionTag version="1.2.4" commit="3f2a1bc" /> // bottom of the sidebar
Texts are built in for lang de and en and can be overridden with labels. Default test ids
(language-toggle-btn, theme-toggle-btn, support-ticket-trigger, user-avatar-btn, logout-btn, ...) are in
TEST_IDS. The components are plain CommonJS with React.createElement, so CRA, Vite and Jest need no extra config.
Icons are inline (Lucide, ISC, see licenses/).
Sidebar, switcher and page blocks (React)
Every app has the same frame (UISPEC "Navigation"): switcher, header with the context select on the left, then the sidebar with search, at most one main action, "Übersicht", up to three sections and "Verwaltung" last (Protokoll, Konten und Rollen, Einstellungen). Profile and personal pages live only in the user menu. The app describes its navigation as data; the sidebar, the phone drawer and (later) the search read the same list.
import { NavLink } from "react-router-dom";
import { AppShell, Sidebar, ProductSwitcher, ContextSelect, SupportDrawer, useScalioApps,
KpiTile, EmptyState, Loading, Notice, StatusBadge, LABELS } from "@scalio-cloud/ui/react";
const L = LABELS[lang];
const NAV = { sections: [
{ id: "main", items: [{ to: "/", label: L.overview, icon: LayoutGrid, end: true }] },
{ id: "compliance", label: t("Compliance"), items: [
{ to: "/drifts", label: t("Drifts"), icon: GitCompareArrows, badge: open || null, badgeTone: "error" },
] },
{ id: "admin", label: L.administration, items: [
{ to: "/audit", label: L.auditLog, icon: ScrollText },
{ to: "/accounts", label: L.accounts, icon: Users, hidden: !isAdmin }, // hidden items and empty sections disappear
{ to: "/settings", label: L.settings, icon: SlidersHorizontal },
] },
] };
const apps = useScalioApps(() => api.get("/apps").then((r) => r.data)); // once per page load
<AppShell
switcher={apps.features.switcher && <ProductSwitcher current="citadel" links={apps.links} stage={apps.stage} sso={sso} lang={lang} />}
header={<AppHeader ... onMenu={() => setDrawer(true)}
left={<ContextSelect label={L.tenant} value={tenantId} options={tenants} onChange={select} hint={domain} testId="tenant-switcher" />} />}
sidebar={<Sidebar nav={NAV} link={NavLink} lang={lang} version={VERSION} commit={COMMIT}
search={{ onOpen: openPalette, shortcut: "⌘K" }} primaryAction={{ label: t("Scan starten"), icon: Play, onSelect: scan }} />}
drawerOpen={drawer} onDrawerClose={() => setDrawer(false)} // phones: same sidebar in a drawer (test ids with "m-")
drawerTop={<ContextSelect ... testId="m-tenant-switcher" />} // below 640px the header hides the select, the drawer shows it
>
<Routes>...</Routes>
</AppShell>
<SupportDrawer open={supportOpen} onClose={...} lang={lang} client={supportApi(api)} page={location.pathname}
context={[["URL", location.pathname], [L.tenant, current?.name]]} />
With client the drawer has the tabs "Neue Anfrage" (category, subject, description, technical details) and
"Meine Tickets" (list, answers, reply, rating); tickets go to the central support desk in Compass (scalio-docs ADR
0022), the backend routes come from scalio-connect scalio_connect.support.web. supportApi(api) takes the app's
axios instance. The settings card <SupportConnection client={supportApi(api)} lang={lang} /> connects the
installation with a one-time code (admins). Without client the drawer keeps the old single form with onSubmit.
supportApi(api, { ideas: true }) adds the tab "Ideen" (Scalio Ideen, ADR 0050; backend router
scalio_connect.support.ideas_web.ideas_router, scalio-connect 0.8.0): ideas of the app with votes and comments and
"Idee vorschlagen" with similar ideas; the ticket category "Wunsch" then moves there.
The settings card <BeaconConnection client={peersApi(api)} lang={lang} /> pairs the app with Scalio Beacon
(address and one-time code from Beacon, Einstellungen -> Verbundene Apps); findings then go to Beacon. Backend:
scalio-connect 0.8.0 peers.web.admin_router.
link is the router's NavLink (it gets to, end, a className function and onClick); without it the
sidebar renders plain links and marks the item for activePath. Item test ids default to nav-<path>
(nav-overview for /), or set testId. Page blocks: KpiTile (tone error = red number),
EmptyState (children = next step), Loading, Notice (error red = Fehler, warn/important orange =
wichtig, ok/success green = gute Meldung, info/action blue = Aktion; banner = full width), StatusBadge
(tones neutral, running, ok, warn, error) and PageHeader with back for detail pages.
Pages that belong together under one menu item keep their own addresses: the item lists them in match
({ to: "/groups", label: "Gruppen", match: ["/teams", "/distribution-lists"] }, pass activePath with
link) and each page shows <SubNav items={[{ to: "/groups", label: "Sicherheit", end: true }, ...]} link={NavLink} />
on top. An app with two work areas (Nexus Identity and HR) puts <WorkspaceSwitcher options value onSelect />
into the sidebar's top.
Page actions in the sidebar (from 0.18.0, UISPEC section 2): <Sidebar pageActions ... /> keeps Übersicht (the
first item of a first section without heading) on top and adds a slot right below it. A page wraps its actions in
<PageActions>; from 768px they move into the slot, on phones they stay in the page header. The slot takes no
room on pages without actions; with actions it pushes the menu down.
<PageHeader title="Kontakte" actions={<PageActions><Button onClick={add}><Plus /> Kontakt</Button></PageActions>} />
<PageHeader title={doc.number} actions={<PageActions><Button variant="outline" asChild><Link to="/invoices"><ArrowLeft /> Zurück</Link></Button></PageActions>} />
Only actions that start something or lead away go there (Neu …, Import, Scannen, Zurück), at most two per page.
Labels without "Neu"/"New": the + icon says it ("+ Kontakt", not "+ Neuer Kontakt").
Filters, search, tabs and Speichern stay on the page. Apps with their own sidebar place <ActionSlot /> themselves.
Settings page (React)
Every area of the settings has its own address (/settings/<area>); the app's router picks the area, the
layout draws the sub-navigation, the crumb ("Einstellungen › Verbindungen") and the title. Groups general,
connections, notifications, security and system get their names from LABELS and always sit in this
order, the app's own groups (with label) come after "Allgemein". Hidden areas and empty groups disappear, below
three areas (minItems) there is no sub-navigation. The search looks at label, hint, group and keywords;
search={false} leaves it out, search={<OwnSearch />} puts the app's own search there.
import { SettingsLayout, SettingsSection } from "@scalio-cloud/ui/react";
const GROUPS = [
{ id: "general", items: [{ id: "general", to: "/settings/general", label: "Instanz", hint: "Name, Sprache, Zeitzone" }] },
{ id: "flows", label: "Abläufe", items: [{ id: "onboarding", to: "/settings/onboarding", label: "Onboarding" }] },
{ id: "connections", items: [{ id: "sync", to: "/settings/sync", label: "Synchronisierung", keywords: ["Intervall"] }] },
{ id: "system", items: [{ id: "updates", to: "/settings/updates", label: "Updates", hidden: saas }] },
];
<SettingsLayout groups={GROUPS} active={area} link={NavLink} onSelect={(i) => navigate(i.to)} lang={lang}>
<SettingsSection id="cloud-sync" title="Cloud-Synchronisierung" description="Intervall und Umfang."
footer={<Button onClick={save}>Speichern</Button>}>
...form fields (shadcn)...
</SettingsSection>
</SettingsLayout>
Sign-in page and profile (React)
All sign-in pages use the same frame and order: Microsoft first, then "oder mit E-Mail", the password field with "Passwort vergessen?" below it, the submit button. Forgot password, reset, verify email and the two-factor step are their own pages in the same frame. Show only what the app can do (register only in Compass, demo accounts only in demo mode).
import { AuthLayout, AuthHeader, MicrosoftButton, AuthDivider, AuthMessage, ProfileSection } from "@scalio-cloud/ui/react";
<AuthLayout
brand={{ mark: "N", name: "Scalio Nexus", claim: "Alle Konten aus einer Konsole verwalten.",
features: ["Onboarding", "Ändern", "Offboarding"] }}
lang={lang} onLangChange={setLang} // top right
theme={theme} onThemeChange={setTheme} // optional
footer={<VersionTag version={v} />} // optional
>
<AuthHeader eyebrow="Zugang" title="Anmelden" subtitle="..." />
<MicrosoftButton href="/api/auth/sso/login" lang={lang} disabled={!ssoReady} hint="..." />
<AuthDivider lang={lang} />
<form>...</form>
<AuthMessage kind="error">{error}</AuthMessage>
</AuthLayout>
// Profile page: the same sections in every app, leave out what an app does not need.
<ProfileSection id="account" lang={lang}>...</ProfileSection> // Konto
<ProfileSection id="appearance" lang={lang}>...</ProfileSection> // Darstellung
<ProfileSection id="language" lang={lang}>...</ProfileSection> // Sprache und Region
<ProfileSection id="security" lang={lang}>...</ProfileSection> // Sicherheit
<ProfileSection id="connections" lang={lang}>...</ProfileSection> // Verknüpfungen
Content of the sections (same in every app, reference Nexus): ProfileIdentity in Konto, ThemeChoice in
Darstellung, LanguageChoice + TimezoneChoice in Sprache und Region, PasswordChange + SecurityFactors in
Sicherheit. The app stores the time zone (POST /auth/preferences {timezone}), calls setUserTimezone() with
the value from /auth/me and formats dates with timeZone: currentTz(). applyTheme(mode) sets the dark class
for light, dark and system.
Other form fields and buttons (e.g. the name field) stay the app's own (shadcn), so they match the rest of the app.
User administration (React)
Konten und Rollen → Benutzer, the same in every app (ADR 0059). The backend follows scalio-docs
interfaces/app-users.md; usersApi(api, {base: "/admins"}) for Nexus.
import { UserAdmin, usersApi, MicrosoftConnection } from "@scalio-cloud/ui/react";
const users = usersApi(api); // once, outside the component
<UserAdmin client={users} me={user.id} lang={lang} roles={[{ value: "admin", label: t("Administrator") }, ...]}
defaultRole="operator" signInModes={["microsoft", "password"]} microsoftEnabled={ssoOn} canLock
accessColumn={{ label: t("Customers"), render: (u) => ... }} accessField={(value, onChange, role, u) => ...}
notify={(text, tone) => toast[tone](text)} confirm={confirm} formatError={fmt} />
// Profile -> Verknüpfungen
<MicrosoftConnection linked={user.microsoft} canUnlink={user.has_password} onLink={...} onUnlink={...} lang={lang} />
Rollen und Microsoft Entra (RoleAdmin, RoleSourceSettings)
import { RoleAdmin, rolesApi, RoleSourceSettings } from "@scalio-cloud/ui/react";
const roles = rolesApi(api); // Nexus: rolesApi(api, { permissions: "/permissions" })
<RoleAdmin client={roles} lang={lang} notify={toast} confirm={confirm} onChange={reloadUsers} />
<RoleSourceSettings value={source} tenants onSave={({ source, tenants }) => api.put("/users/role-source", { source, tenant_ids: tenants })} lang={lang} />
The backend is scalio-platform roles_router (0.3.0). Built-in roles are read-only; own roles never get the
permissions the app keeps for administrators.
Setup wizard (React)
Einrichtungsassistent after the first sign-in, the same in every app (ADR 0060, UISPEC section 3). The app writes
only its steps; the backend follows scalio-docs interfaces/setup-wizard.md (GET/PUT /api/setup/wizard).
import { SetupWizard, SetupWizardButton, useSetupWizard, setupWizardApi } from "@scalio-cloud/ui/react";
const wizardClient = setupWizardApi(api); // once, outside the component ({ base: "/wizard" } for another route)
function AppWizard() {
const w = useSetupWizard({ client: wizardClient, enabled: isAdmin }); // opens by itself while needed
const steps = [
{ id: "tenant", label: t("Tenant"), title: t("Connect Microsoft"), description: "...", content: <TenantsSection />,
canNext: connected, onNext: async () => { /* save; a rejected promise keeps the step open */ } },
{ id: "mail", label: t("Email"), title: t("Email (SMTP)"), content: <SmtpForm />, optional: true },
];
return <SetupWizard {...w.props} lang={lang} title={t("Set up Foundry")} subtitle={t("...")} steps={steps}
success={{ title: t("Setup complete"), text: t("...") }} />;
}
// Einstellungen: open it again (admins only)
<SetupWizardButton lang={lang} />
Test IDs: setup-wizard, wizard-stepper(-item), wizard-step-{id}, wizard-next, wizard-back, wizard-skip,
wizard-close, wizard-error, wizard-success, wizard-done, settings-open-wizard.
Two-factor card and license panel (React)
The same backend routes in every app, so the apps pass only their axios instance and a few helpers.
import { SecurityFactors, securityApi, LicensePanel, licenseApi } from "@scalio-cloud/ui/react";
import { QRCodeSVG } from "qrcode.react";
import { startRegistration, browserSupportsWebAuthn } from "@simplewebauthn/browser";
const security = securityApi(api, { startRegistration, browserSupportsWebAuthn }); // once, outside the component
const license = licenseApi(api);
// Profile -> Sicherheit: authenticator app, backup codes, passkeys. Hidden for accounts without a password.
<SecurityFactors client={security} lang={lang} product="Scalio Citadel" email={user.email}
qr={(uri) => <QRCodeSVG value={uri} size={160} />} notify={(text, tone) => toast[tone](text)}
confirm={confirm} onMfaChange={(on) => setUser({ ...user, mfa_enabled: on })} />
// Einstellungen -> System -> Lizenz (scalio-license backend: /license, /license/addons)
<LicensePanel client={license} lang={lang} product="Citadel" kindLabel={licenseKindLabel}
featureLabel={licenseFeatureLabel} noFeatures={t("Drift detection only")} notify={...} confirm={confirm} />
// noFeatures only when the app has a better text than «Nur die Grundfunktionen»
securityApi reads snake_case and camelCase answers. notify(text, tone) gets "success" or "error"; without it
the blocks show the message themselves. confirm is the app's dialog ({title, action, danger} -> Promise),
window.confirm otherwise. formatError(err) lets the app phrase API errors (for example license limit codes). A license backend with other routes passes its own client and a map(license, {L})
that returns the view model described in react/license.js. Nexus does this: its map adds trial badges, seat hints, invalid
add-ons and read-only notes (demo mode), and passes its trial notices as statusExtra and the reminder settings as children.
Since 0.27.0 (ADR 0069) the panel shows three areas in the settings form (title and hint left, card right): «Status und
Nutzung», «Add-ons», «Lizenzschlüssel». children with extraTitle (and extraHint) becomes a fourth area. The panel passes its lang to every client call;
licenseApi sends it as ?lang=, so scalio-license 0.4.0 renders warnings and errors in the language of the page.
Gruppe System: Zurücksetzen and helpers (React)
Every app shows Einstellungen → System in the same order: Lizenz, Updates, Sicherung, Zurücksetzen (settingsGroups
sorts the group system that way). The reset block talks to scalio-platform factory_reset
(GET/POST /api/admin/factory-reset):
import { FactoryReset, factoryResetApi, SettingsField, SettingsRow } from "@scalio-cloud/ui/react";
const reset = factoryResetApi(api); // once, outside the component
<FactoryReset client={reset} lang={lang} tokenKey="citadel_token" backupTo="/settings/backup" link={Link} />
SettingsField (label, field, help) and SettingsRow (title and help left, a switch or button right; tone="danger")
keep the fields inside a SettingsSection the same in every app.
Benachrichtigungen: E-Mail-Versand und E-Mail-Vorlagen (React)
Since 0.29.0 (ADR 0069 W5) every app sends mail the same way. The server side is scalio-platform 0.8.0 mail
(Mailer.router, mounted at /api/mail). Both blocks go into the settings group «Benachrichtigungen».
import { MailTransport, MailTemplates, mailApi } from "@scalio-cloud/ui/react";
import { RichTextEditor } from "@/components/RichTextEditor"; // optional, the app's own editor (Compass: react-quill-new)
const mail = mailApi(api); // once, outside the component; mailApi(api, "/admin/mail") for another prefix
<MailTransport client={mail} lang={lang} notify={toast} graphHint={t("...Mail.Send and Mail.ReadWrite...")}>
<InboxSettings /> {/* optional blocks after «Testmail» */}
</MailTransport>
<MailTemplates client={mail} lang={lang} editor={RichTextEditor} />
MailTransport shows three blocks: «E-Mail-Versand» (off, SMTP or Microsoft 365, sender, language; secrets come back
as a mask and the mask is sent back unchanged), «Testmail» (empty recipient: to me) and «Letzte Mails» (outbox={false}
hides it). On a test instance the backend answers test_mailbox: true and the server fields are locked.
MailTemplates picks mail and language, offers the placeholders as buttons, shows the preview of the real rendering
in a sandboxed frame, and saves, tests («Test an mich») or resets to the built-in text. A switchable mail gets the
checkbox «Diese Mail senden». The editor gets {value, onChange, testId}; without it a text field is used, so this
package keeps no dependencies. With an editor, plain built-in texts are passed as paragraphs (textToHtml).
Protokoll: Aktionen und Jobs (React)
One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
audit_router and jobs_router (0.7.0).
import { AuditNav, AuditLog, JobsPanel, auditApi, jobsApi } from "@scalio-cloud/ui/react";
const audit = auditApi(api); // GET /audit, /audit/export.csv
const jobs = jobsApi(api); // GET /jobs, /jobs/runs, /jobs/queue, POST /jobs/{key}/run, queue actions
<PageHeader title={L.auditLog} nav={<AuditNav link={NavLink} lang={lang} />} />
<AuditLog client={audit} lang={lang} notify={toast} actionLabel={(a, lang) => ACTIONS[lang][a]} tenantLabel="Kunde" />
<JobsPanel client={jobs} lang={lang} notify={toast} canRun={can("jobs.run")} link={Link} confirm={confirm} />
AuditLog: note «unveränderlich», chips with counts (categories from the server), search, filters for action, status, tenant and time range, sortable columns, column picker (person, tenant, IP), CSV with the same filters, «Ältere laden» (cursor), view with «alt → neu», cards on phones. Extras:toolbar,rowActions(entry),renderDetail(entry)(Nexus KI, Citadel PDF, Nexus steps).JobsPanel: tabs Warteschlange (only when the app has one), Läufe (filter by job and status) and Hintergrundjobs (schedule, last run, last result, «Jetzt ausführen» withcanRun); reloads every 15 seconds (pollMs). Extras:queueActions(item, reload),renderQueueDetail(item, reload).AuditNav: the tabs/auditand/jobs(basefor another prefix,jobs={false}hides Jobs).
Command palette (React)
Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
import { CommandPalette, openCommandPalette, paletteShortcut } from "@scalio-cloud/ui/react";
<Sidebar nav={nav} search={{ onOpen: openCommandPalette, shortcut: paletteShortcut(lang) }} ... />
<CommandPalette nav={nav} activePath={location.pathname} onNavigate={navigate} lang={lang}
pages={[{ to: "/profile", label: "Profil" }]}
groups={(query) => [{ id: "ai", label: "KI", order: 10, items: [{ id: "ask", label: `Fragen: ${query}`, onSelect: ask, hidden: !query }] }]} />
Pages: every visible sidebar item plus pages (each path once), searched by label, keywords, section and path.
Groups sort by order (default 60); recent pages (50) show only while the query is empty, pages come last (100).
An item has label, icon, to or onSelect, and optional keywords, hint, tone (primary, danger),
testId and alwaysShow. Without open the palette handles ⌘K / Ctrl+K and openCommandPalette() itself;
pass open + onOpenChange to control it. recentKey sets the localStorage key (false: no recents).
Installing
Public on npmjs.com, no token needed: yarn add @scalio-cloud/ui (pin the exact version).
The package holds only design values and fonts under the SIL OFL, nothing confidential.
License
Design tokens and preset: (c) Scalio Cloud LLC, free to use in Scalio apps. Fonts: SIL Open Font License 1.1,
see fonts/licenses/. Icons in react/icons.js: Lucide, ISC license, see licenses/lucide-LICENSE.txt.
Release
- Change the files and bump
versioninpackage.json(semver; tag withoutv). - Merge to
main, then release it: in the Scalio Dev cockpit (row Scalio UI, "Freigeben" with the version) or in GitHub (Actions -> publish -> Run workflow on main, or create the tag/release yourself). Thepublishworkflow tests and publishes that version to npmjs.com via trusted publishing (no token; npm orgscalio-cloud, trusted publisher Scalio-Cloud / scalio-ui / publish.yml) and, when started by hand or from the cockpit, creates the tag and GitHub release with the CHANGELOG lines. The changelog lives in scalio-docschangelog/scalio-ui/(ADR 0066): every change adds a filechangelog.d/<name>.mdthere, and before the release a docs PR runspython3 tools/changelog.py --repo scalio-ui release <version>. - Apps update with
yarn upgrade @scalio-cloud/ui@<version>in their own PR.
npm test checks: light/dark variables match, the preset only uses defined variables, all font files exist,
nothing is loaded from the network.