Novus Technologies Novus Technologies design kit v0.9.0
Menu

Navigation

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.

novabank
Console header: the app switcher first, then the product lockup
<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 requiredRoles is 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 https addresses on the allowed host suffixes are linked (by default .novustech.dev and .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.

NameKindMeaning
currentattributeThis console's app id. Its tile shows as current.
launcherattributeThe workspace launcher's origin, such as https://workspace.novustech.dev. Leave it out on a bank's own deployment.
catalogattributeThe catalog's address, when it is not {launcher}/api/catalog.
allowed-hostsattributeHost suffixes an app may live on, separated by spaces. A leading dot admits subdomains only.
fallbackattributeBase64 JSON of the deployment's static list, [{key, name, url}]: the apps setting consoles carry today.
labelsattribute (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.
claimspropertyThe viewer's realm roles and group names, as an array of strings.
novus-app-openeventFired 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.

CSS

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.

@pattern app-switcher
/* 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; }
}