Menu
General
ButtonBadgePillsAvatarBrand lockupLayout
ContainerGrid & RowSurface & text utilitiesApp shellPage headerData Display
CardTableChartsCalendarStatsBulletsLeadersLogo wallCollapse & DisclosureTimelineValue chainDescriptionsFeedback
AlertProgressModalSlide-overSkeletonEmpty stateForms
Field & InputDropdownLead form & CTA rowDate pickerFilter barNavigation
NavSide navigationTop navigationApp switcherUser menuStepperTabsBreadcrumbPaginationTemplates
Sign-in pageSigned-out pageSettings layoutTheming
Browser tabProduct themesNavigation
App switcher
The Novus app switcher, first in every console header: search and a grid of the apps the viewer may open, read from the workspace launcher's catalog.
novus-app-switcher · .nv-apps__button · .nv-apps__panel · .nv-apps__grid · .nv-apps__tile · .nv-apps__mark
One switcher for every Novus console, after the Microsoft 365 app launcher: a nine-dot button at the far left of the header opens a panel with a search field and a grid of the apps the signed-in person may open. It reads the workspace launcher's catalog at run time, so an app added to the workspace reaches every console without a rebuild.
<header class="appbar" style="border:1px solid var(--border);border-radius:var(--radius-md)">
<div class="appbar__inner appheader">
<div class="appheader__left">
<novus-app-switcher current="novabank" launcher="https://workspace.novustech.dev" catalog="../demos/app-switcher/sample-catalog.json"></novus-app-switcher>
<span class="theme-novabank wm"><span class="nm">nova</span><span class="sf">bank</span></span>
</div>
<div class="appheader__right"><span class="appheader__endorse"><span class="brandmark" role="img" aria-label="Novus Technologies"></span></span></div>
</div>
</header>
Full-page demos, each with a viewer picker: side-navigation shell and top-navigation shell.
How it behaves
- First in the header. Nothing sits to the left of the switcher, in either shell. On a phone the drawer toggle follows it, then the product lockup. A console draws one grid button, never two, and the layout audit fails a header that places anything before it.
- Search first. The field takes focus when the panel opens and filters by name, description and category as you type. When nothing matches it says so in words.
- Tiles. Each tile is the product's mark with its name under it, and the description as its tooltip and accessible description. Five to a row on a desktop, four on a tablet, three on a phone. This console's own tile is marked as current and goes nowhere.
- Order. The last four apps this console's switcher opened come first, then the catalog's category order, then catalog order.
- All apps is the last tile whenever a workspace launcher is declared, and opens it, where every app is grouped by category and can be pinned.
- Keyboard. The arrows walk the grid as drawn, Home and End jump to its ends, typing goes to the search field, Tab stays inside the panel, and Escape closes it and returns focus to the button. A press outside closes it too.
- Same tab. Every app signs in through the same realm, so a second tab would only be a second copy of the session.
Who sees what
- An app shows when one of its
requiredRolesis among the claims the console hands over: the viewer's realm roles and group names. An entry with no roles, a malformed entry, or a viewer with no claims shows nothing. - Only
httpsaddresses on the allowed host suffixes are linked (by default.novustech.devand.novustech.id). Any other entry is dropped, so a tampered catalog cannot send an operator to a look-alike host. - The catalog is fetched when the panel first opens, never at page load, and is given three seconds. If it does not answer, or answers something unreadable, the switcher uses the last good copy this browser kept, then the deployment's static list, then only "All apps". The page never waits on it.
- A bank's own deployment, whose staff do not sign in to the Novus workspace, declares no launcher: its switcher shows the static list and no "All apps" tile.
Markup
Load js/novus-app-marks.js and js/novus-app-switcher.js with
console.css, as plain scripts or as imports from a bundler, and place the element
first in .appheader__left.
| Name | Kind | Meaning |
|---|---|---|
current | attribute | This console's app id. Its tile shows as current. |
launcher | attribute | The workspace launcher's origin, such as https://workspace.novustech.dev. Leave it out on a bank's own deployment. |
catalog | attribute | The catalog's address, when it is not {launcher}/api/catalog. |
allowed-hosts | attribute | Host suffixes an app may live on, separated by spaces. A leading dot admits subdomains only. |
fallback | attribute | Base64 JSON of the deployment's static list, [{key, name, url}]: the apps setting consoles carry today. |
labels | attribute (JSON) or property (object) | The element's own words in the console's language: apps, find, grid, all, allTitle, noMatch, noApps, loading, countOne, countOther (with {n} for the number). A key left out keeps its English; every label is set as text. |
claims | property | The viewer's realm roles and group names, as an array of strings. |
novus-app-open | event | Fired before the browser navigates, with the app id as its detail. It bubbles and is cancelable, so a host can keep the navigation for itself. |
<!-- A plain-script console -->
<novus-app-switcher current="novatrace" launcher="https://workspace.novustech.dev"></novus-app-switcher>
<script>document.querySelector("novus-app-switcher").claims = session.roles.concat(session.groups);</script>
// A React console: attributes in JSX, the claims property set through a ref
import "novus-design-kit/js/novus-app-marks.js";
import "novus-design-kit/js/novus-app-switcher.js";
const ref = useRef(null);
useEffect(() => { ref.current.claims = [...roles, ...groups]; }, [roles, groups]);
<novus-app-switcher ref={ref} current="novabank" launcher={launcherOrigin} fallback={apps} />
The catalog
The workspace launcher answers GET /api/catalog with CORS open to any origin, no
credentials and a five-minute cache. It holds names, addresses, categories and role names, and
nothing secret.
{ "version": 1,
"categories": [{ "id": "core-banking", "name": "Core banking" }],
"apps": [{ "id": "novabank", "name": "NovaBank", "description": "Core-banking console",
"category": "core-banking", "url": "https://novabank.novustech.dev",
"mark": { "colour": "(the product colour)", "glyph": "novabank" },
"requiredRoles": ["operator", "checker", "administrator"] }] }
Product marks
Each glyph names a mark in js/novus-app-marks.js: a square of the
product's colour with a white line glyph, 24 units, stroke 1.8, round caps. The colours are fixed
product identity, taken from the palette in tokens.css, and keep in the dark theme.
The same marks ship as placed assets in logos/marks/. NovaMerchant's three marks share
NovaMerchant's colour and are told apart by glyph. An app with no mark shows its initial on grey.
novabanknovahubnovacardnovatracenovaplannovasearchnovaticketnovaedgenovus-iddesign-kitibankingmbankingnova-lendingnovamerchantnovamerchant-posnovamerchant-billerCSS
These rules ship in the console pattern layer, admin-kits/shared/novus-admin.css, which this site and every Admin Kit flavor load after tokens.css. Copy the whole layer, or only the sections below; every value is a token.
/* The Novus app switcher (feature 014), drawn by <novus-app-switcher> from
js/novus-app-switcher.js. It is the first control in a console header in either
shell: nothing sits to the left of it, and on a phone the drawer toggle follows
it. A 40px nine-dot button, pressed through a 44px hit area, opens a panel
anchored under it at the left edge: 640px wide on a desktop, a full-width sheet
under the header below 600px. Search comes first, then the app tiles, five to a
row on a desktop, four on a tablet and three on a phone, with "All apps" last.
Surface, border, radius and elevation follow the theme, while each product mark
keeps its own colour in both themes. */
novus-app-switcher { position: relative; display: inline-flex; flex: 0 0 auto; }
novus-app-switcher[hidden] { display: none; }
.nv-apps__button { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; transition: background-color var(--dur-fast) var(--ease-out); }
.nv-apps__button::after { content: ""; position: absolute; inset: -0.125rem; }
.nv-apps__button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
.nv-apps__button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 14%, transparent); }
.nv-apps__button:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.nv-apps__panel { position: absolute; inset-inline-start: 0; top: calc(100% + var(--space-2)); z-index: 70; box-sizing: border-box; width: min(40rem, calc(100vw - 2 * var(--gutter))); max-height: calc(100vh - 6rem); overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); font-size: var(--text-sm); line-height: var(--lh-sm); text-align: start; animation: nvappsin var(--dur-fast) var(--ease-out); }
.nv-apps__panel[hidden] { display: none; }
.nv-apps__panel:focus { outline: none; }
.nv-apps__search { position: relative; display: flex; align-items: center; }
.nv-apps__lens { position: absolute; inset-inline-start: var(--space-3); color: var(--text-muted); pointer-events: none; }
.nv-apps__input { min-height: 2.75rem; padding-inline-start: calc(var(--space-3) + 1rem + var(--space-2)); }
.nv-apps__status { margin: 0; color: var(--text-secondary); }
.nv-apps__grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-1); }
.nv-apps__grid > li { min-width: 0; display: flex; }
.nv-apps__tile { box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: var(--space-2); width: 100%; min-width: 0; padding: var(--space-3) var(--space-1); border-radius: var(--radius-md); color: var(--text); text-decoration: none; transition: background-color var(--dur-fast) var(--ease-out); }
a.nv-apps__tile:hover { background: var(--bg-subtle); color: var(--text); text-decoration: none; }
.nv-apps__tile:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.nv-apps__tile--current { background: var(--accent-subtle); cursor: default; }
/* A product mark: a square of the product's colour with its white line glyph. */
.nv-apps__mark { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: var(--space-7); height: var(--space-7); border-radius: var(--radius-lg); color: var(--neutral-0); font-size: var(--text-lg); font-weight: var(--weight-medium); line-height: 1; }
.nv-apps__mark--all { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-strong); }
.nv-apps__name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 899.98px) { .nv-apps__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 599.98px) {
.nv-apps__panel { position: fixed; inset-inline: 0; top: var(--nv-apps-top, 3.5rem); width: auto; max-height: calc(100dvh - var(--nv-apps-top, 3.5rem)); padding: var(--space-3) var(--gutter) var(--space-4); border-inline: 0; border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.nv-apps__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@keyframes nvappsin { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) {
.nv-apps__panel { animation: none; }
.nv-apps__button, .nv-apps__tile { transition: none; }
}