Nova UI: layout choice, tokens, and the mocked-API harness
Updated
The web client has two chromes. Ribbon is the original one; Nova is a module rail, a menu tree, a palette, and a restyled list and card. Which one a user sees is decided by one small pure function from three inputs, and everything else in Nova hangs off that decision.
Where the choice lives#
The layout is a property of the interface template, the object that already maps users to a menu. A template carries two fields, and a user carries one preference:
| Where | Field | Values |
|---|---|---|
MetaInterfaceTemplate (src/ZuloOne.Shared/Metadata) |
Shell |
ribbon, nova, or null = no preference. Up to 16 characters. |
MetaInterfaceTemplate |
ShellLocked |
bool. Users may not choose their own layout. |
AppearancePrefs.shell (frontend/src/lib/userPrefs.ts) |
shell |
ribbon, nova, or absent = "by template". Stored in localStorage per user. |
A separate "layout assignment" object was rejected because the template already is the assignment: users are linked to it, there is a default template, and Assign to users… exists. A new object would need a second assignment screen and a rule for which one wins.
The migration, and its PostgreSQL twin#
One migration adds both columns to MetaInterfaceTemplates:
| Provider | Project and file | Shell |
ShellLocked |
|---|---|---|---|
| SQL Server | src/ZuloOne.Data/Migrations/20261029100000_InterfaceTemplateShell.cs |
nvarchar(16) NULL |
bit NOT NULL, default 0 |
| PostgreSQL | src/ZuloOne.Data.Pg/Migrations/20261029100000_InterfaceTemplateShellPg.cs |
character varying(16) NULL |
boolean NOT NULL, default false |
The two providers keep separate migration folders and separate model snapshots. The PostgreSQL class carries the Pg suffix in its name and migration id, with the same timestamp, so the history stays comparable. Adding a column means writing both migrations and updating both ZuloOneDbContextModelSnapshot.cs files. The Core applies pending migrations on start (Database.Migrate() in Program.cs), so no manual step exists on a stand.
The API#
GET /api/metadata/menu/my answers { templateId, shell, shellLocked, items }. The template is the user's assigned one, else the one marked default; with neither, shell is null and shellLocked is false. GET /api/metadata/menu/templates returns both fields for the admin page. POST and PUT /api/metadata/menu/templates normalise the value (trim, lower case) and refuse two things with a 400:
- a
shellthat is notribbon,nova, or empty:Shell must be 'ribbon', 'nova' or empty; shellLockedwith noshell:ShellLocked requires Shell.
POST …/templates/{id}/clone copies both fields. The Menu builder page (frontend/src/pages/menu/MenuPage.tsx) has Layout (data-testid="template-shell") and Forbid users to change it (template-shell-locked); clearing the layout also clears the lock before the PUT, because the server would refuse the pair.
The lock is a presentation policy, not a permission. The server does not enforce it, and nothing else depends on it. resolveShell ignores a stored personal choice while the lock holds, so the user cannot get around it through the UI.
resolveShell and useEffectiveShell#
frontend/src/layout/resolveShell.ts is pure and has the whole rule:
- If the template names a layout and is locked, use the template's layout.
- Else the user's own choice.
- Else the template's layout.
- Else
DEFAULT_SHELL(ribbon, inlib/userPrefs.ts).
Three decisions inside it are deliberate:
- A lock counts only with a recognised layout.
canChooseShellis false only when the lock is on andasShellKind(templateShell)isribbonornova. A lock with no layout, or with a value this build does not know, never strands the user without a way to choose. - Unknown values are "none".
asShellKindmaps anything other thanribbonandnovatoundefined. A newer server can add a third chrome, and an older client falls back to the ribbon instead of rendering nothing. - The personal choice is kept while the lock holds. Removing the lock gives the user their old choice back.
useEffectiveShell (layout/useEffectiveShell.ts) feeds the rule. It reads ['menu-my', lang], the same query key as AppLayout, so there is one fetch. While the menu loads, it returns the last resolved value, cached under prefKey(userId, 'shell', 'last'). Without that cache a Nova user would see the ribbon for the length of the menu request on every reload.
AppLayout (layout/AppLayout.tsx) switches the frame on the result: Nova adds an AppShell navbar and replaces the ribbon in the header with NovaTopBar. The navbar is a module rail (NovaRail, 64 px icons by default: the ZuloOne mark is Home on every rail state, then one icon per module, then Developer, Administrator, and Settings, and a compact user circle at the very bottom). The user menu is sign-out only — Settings is the rail row. The unpinned console overlay stays in the work column (--nova-nav-offset = the rail, or the pinned navbar). It does not cover the rail (navbar z-index 200, console 190, curtain 180). The status-bar footer uses Mantine's alt-layout margin-inline-start for the same gap — a second inset-inline-start would double the rail and clip the right edge. Cancelled fetches (opening the curtain aborts in-flight list queries) are not written to the console. The tenant logo belongs in the curtain header, not on the rail. A chevron (Show names / Hide names, nav.railLabels, default off) widens the rail to 196 px and writes the names next to the icons. Clicking a module (or Developer / Administrator) slides out a curtain with that module's tree (NovaBrand large tenant logo + name, favorites / recents / sections, full user card). Search stays on the top bar (Ctrl+K). Favorites and Recent fold from their headings; the choice is stored with the section collapses in nav.collapsed. The curtain is hidden by default (useNovaCurtain, key nav.curtainPin, default unpinned). Unpinned it overlays the work area and closes after a leaf, an outside click, Escape, or the same module again; pinned it docks beside the rail and the work area shrinks. The user circle shares a View Transition name with its curtain copy so it FLIPs when the curtain opens (runNovaMove calls document.startViewTransition as a method — an unbound extract throws Illegal invocation). The Home mark stays on the rail. The resizer is only on a pinned curtain. Below the md breakpoint the rail shows names and the tree go into the drawer together. AppLayout also mirrors data-shell and data-material onto <html> (useNovaRootAttrs) so portaled content gets the --nv-* tokens. List and card code reads the same hook: DocumentJournalWindow, DictionaryDataWindow, RecordWindows, CommandBar2, and QuickSearch. The Ribbon path of each is the unchanged code; Nova is added around it, not instead of it.
Strings: locales/nova/*#
Nova strings do not live in the four big locale tables. frontend/src/i18n/locales/nova/ has one module per feature — shell.ts, palette.ts, list.ts, card.ts, settings.ts — and each exports en, ru, uk, and ar records. nova/index.ts merges them into novaMessages, and each of en.ts, ru.ts, uk.ts, ar.ts spreads its language at the end of its own table. Reasons: parallel work on different features never edits the same 4,000-line file, and the spread at the end lets a nova.* key override a same-named older one. Keys follow nova.<feature>.<name>; the one exception is appear.railBg, which sits next to the other palette slot captions.
To add a string, add the key to all four records of the one module. frontend/src/i18n/locales.test.ts checks key parity between languages, so a missing translation fails npm run test:unit. Use the exact English string in documentation: the user pages copy their labels from these records.
Design tokens#
- Presets. Classic Theme presets live in
kit/themePresets.tsand show only while the Classic (ribbon) layout is on. Nova has its own colour schemes inkit/novaGlass.ts:canopy(brand green, the default),azure,ember,chrome,fluent. They appear in Settings → Appearance while Nova is on. Each scheme setsdata-nova-scheme;--nv-*tokens innvTokens.cssfollow it on both matte and glass.withNovaDefaultsparks the Classic palette and paints the scheme's light or dark palette so Mantine chrome matches. Switching back to Classic restores the saved palette. - Material. The user picks Matte or Glass in Settings → Surface material (
AppearancePrefs.material, defaultglass, read throughuseMaterial()), shown only while Nova is the effective layout.kit/nova/nvTokens.cssis the token contract: every Nova surface reads--nv-*variables (ink, cards, hairlines, shadows, accent, status colours) in light and dark.data-material="matte"is opaque;data-material="glass"makes the work area transparent over an aurora background and the cards translucent with a blur. Dark scheme tints set an opaque--nv-bg-appfor matte; glass overrides it back to transparent so Azure / Ember / Chrome / Fluent keep the same three glow spots as Canopy, not only in the sidebar. Glass falls back to opaque surfaces underprefers-reduced-transparencyandprefers-contrast: more.PanelShelland theAppShellmain area takevar(--nv-bg-app), which is transparent in glass; outside Nova the variable is unset and they keep the palette colour.kit/nova/novaSurfaces.csscarries the same material to panels that models and designers draw themselves: in the work area everySurfaceCard/ MantineCard/Papertakes--nv-card(a panel inside a panel is a plain tint), and inside a modal or drawer Mantine's--mantine-color-body,-default,-default-hoverand-default-borderare re-pointed to the tokens, so the form designers follow the material. The sheet a form is drawn on carriesdata-form-sheetand stays opaque (--nv-sheet). - The rail colour was the palette slot
railBg(Nova module rail background). The module rail is now a transparent column on the material's sidebar (--nv-sidebar) with a hairline, so the slot is not read by the Nova shell. data-shell.AppLayoutsetsdata-shell="nova"ordata-shell="ribbon"on theAppShellroot. Everything that is Nova-only in the shared components is scoped to it: dockview tab styling (workspace/novaTabs.css), the checkbox look in grids (kit/nova/novaList.css), and the touch targets under@media (pointer: coarse)(44 px, innova.css).- Card chrome.
styles.csssets--zuloone-card-radius,--zuloone-card-border, and--zuloone-card-shadowperdata-shell. The radius is the Settings Corner radius slider (--zuloone-control-radius) in both Nova and Classic; Nova also points--nv-r-ctl/--nv-r-cardat that same value so buttons, fields, tabs, the rail and the cards all move together. Nova cards (list sheets, field groups, table parts, inspector) have no hairline —--nv-shadow-cardis the lift, omnidirectional (almost no offset, no0 0 0 1pxring). The record window scroller keeps a gutter sooverflow: autodoes not clip that blur into a hard edge.surfaceCardStyleinkit/SurfaceCard.tsxreads the card vars with the old surface token as the fallback, so panels not under a shell render as before. - No hard-coded colours in Nova components. They take colours from
useSurface, the palette, or CSS variables (var(--nv-*)), so the dark theme, the material and a user's palette reach them. The status pill tones inkit/nova/statusTone.tsarevar(--nv-*)strings too (soft tint of the accent, a dot, text mixed with--nv-ink), so no literal colour remains there. - One type scale. Nova uses only the sizes
--nv-fs-xs12,-sm13,-md14,-lg16,-xl20,-title28 and-num34 px, the weights--nv-fw-reg400,-med500,-semi600 and-bold700, and one family--nv-font; no stylesheet writes a literal size, weight or family. The hierarchy: page and card heading 28/700 (card title 16/600), body and grid cells 14/400, labels and dense UI 13/500–600, column heads, pills and captions 12/600, big figures 34/700 (weight 700 only for the title and the figures). The same block innvTokens.cssre-points Mantine's--mantine-font-size-*, heading and family variables to the scale, sosize="xs|sm|md"text of any component lands on the same stops. - One control skin.
kit/nova/novaControls.cssandnovaFields.cssgive every Mantine control the same look: heights 38 px (sm, the default), 32 px (xs), 44 px (mdand coarse pointers), radius--nv-r-ctl(the Settings corner-radius slider), a 1 px--nv-hair-strongring, a 2 px accent focus ring, one hover step up the fill ramp. Fields are solid--nv-card-solidin matte and translucent in glass (--nv-field-bg*); they carry nobackdrop-filteron purpose (dozens of them sit in table rows).styles.csspaints inputs on the element itself, so the rules target the<input>, not the wrapper's--input-bg; to give one field another look set--nv-input-bg/--nv-input-bdon its wrapper. List view tabs (.nv-seg) read the same segmented tokens. - Navbar width. Unpinned, the shell is the rail: 64 px icons, or 196 px with names. Pinned, the user drags the edge of the curtain (
NovaResizer, arole="separator"handle; arrow keys step by 16 px, Home/End jump to the limits, double-click or Enter resets). The stored width is the icon-rail total (264–484 px, 328 by default = 64 + 264 tree, keynav.width); turning names on adds 132 px to the live shell. While dragging only the--app-shell-navbar-widthvariable on the AppShell root changes; React state is committed on release.
Warning
Mantine modals, drawers, popovers, and menus render in a portal outside the shell root, so
[data-shell='nova']selectors do not reach them. The mobile navigation drawer therefore carries its own class.nova-drawerand the touch rules list it next to[data-shell='nova']. A new Nova style for portaled content needs its own class.
npm run audit:colors#
frontend/scripts/audit-hardcoded-colors.mjs counts #rrggbb literals in frontend/src/**/*.tsx (not tests; it skips themePresets.ts, mantineTheme.ts, and colorIcons.data.ts) and prints the files, most first. It exits with 1 when a Nova first-screen file has one: layout/nova/*, kit/nova/*, components/QuickSearch.tsx, windows/DocumentJournalWindow.tsx, windows/RecordWindows.tsx, kit/StandardGrid.tsx, kit/BaseForm.tsx, components/CommandBar2.tsx. Every other file only adds to the count. To bring another screen under the gate, add its path to NOVA_FIRST_SCREENS in the script after you have removed its literals.
Pure logic and where the state lives#
Behaviour sits in small functions with unit tests (npm run test:unit), and the components only render it:
| Concern | File |
|---|---|
| Modules, sections, breadcrumbs from the menu | layout/novaNav.ts |
| Favorites, recents | layout/navStore.ts |
| Palette items, scopes, ranking | components/palette/paletteItems.ts, lib/paletteRank.ts |
| Saved list views | kit/nova/savedViews.ts, viewState.ts |
| Filter chip text | kit/nova/chipText.ts |
| Bulk pill split | kit/nova/bulkCommands.ts |
| Workflow steps, status tones, the journal's progress bar | kit/nova/stepper.ts, useStepperModel.ts, statusTone.ts, progress.ts |
Cell skins from metadata tags (avatar, avatar:person) |
kit/nova/cellSkin.ts, AvatarCell.tsx |
| Primary command, More groups | kit/nova/commandGroups.ts |
| Card section tabs from header-field metadata, grouped-list rows | kit/nova/fieldTabs.ts, NovaCardTabs.tsx, fieldGroups.ts, NovaFieldGroups.tsx |
| Table-part toolbar: paste from Excel, catalog pick, tagged commands | kit/nova/pasteRows.ts, PartRowDialogs.tsx, tablePartCommands.ts |
State is browser state, through prefKey(userId, namespace, id) in localStorage: nav/state (favorites and recents), nav/module, nav/collapsed, nav/curtainPin (pinned curtain; an unpinned open is not stored), nav/railLabels (module names on the rail, default off), listviews/<gridId>, and shell/last. A saved view stores the whole grid view (GridViewPrefs: sort, filters, density, columns, and the other display flags) and is applied back through StandardGrid's applyView. Moving favorites, recents, or views to the server means changing navStore.ts and the loadViews/persistViews pair; the components do not touch storage directly. api.saveUserSetting exists for that.
Traps in the shell and the card:
- The menu tree is a curtain. The named rail is the only column that stays. Unpinned (the default) the tree overlays the work area and closes after a leaf, an outside click, Escape, or the same module again; pin it to dock and resize.
nav.curtainPinstores the pin, not an unpinned open. - Developer / Administrator favorites use ribbon command keys.
nav/stateis one list.NovaTreeresolves menumetaIds (and shows a favorite from any module while another module is open).NovaPlatformTreeresolves command keys of the open tab (/dictionaries,types-edts). Each tree ignores ids it cannot resolve: a starred Inventory leaf does not appear in Developer, and Dictionaries starred in Developer does not appear in Inventory. - Header fields in a tab are an iOS grouped list, built from the form's own slots.
NovaFieldGroupssplits a tab's fields into two balanced groups (one group for three fields or fewer; animageorbinaryfield goes below, full width, with a stacked label) and wraps each slot in a.nova-row. The «label | value» row is CSS only (novaCard.css, on[data-zulo-field] > .mantine-InputWrapper-root), so required marks, validation, lookups, and Enter-walking are the unchangedFieldShell/DynamicField. The order of Enter-walking is therefore column by column. A reference field getsdata-kind="reference"and the accent colour. If a new widget breaks in a row, give it a kind inisWideKind(fieldGroups.ts) to keep it stacked. - The table part's name lives on its tab. In Nova the part's grid draws no title row and no ⓘ; the tab carries the localized part caption, the ⓘ guide (
grid.tp:<table>), and the row count. The toolbar above the grid is the part's own and has two groups, like a standard grid and its action pane: first the CRUD icon buttons (Add, Edit, Copy, Delete — the last three act on the selected lines), then a separator and the labelled buttons: Pick from catalog (shown when the part has a reference column — the first required one, else the first), the commands tagged for this part, and Paste from Excel; at the end a line search and the view button. A frozen subtype hides everything that changes lines. Padding inside that toolbar is--action-pane-pad/--action-pane-gapon.nova-tp(12px 16px/8px): the pane's own padding is a CSS variable so the card skin can give the buttons room; an inlinepaddingwould win and leave the strip flush with the card edge. - A command goes to a part's toolbar by its group name. Commands have no tags column, so set the command's Group (
groupName) totablepart:<PartName>, for exampletablepart:Lines. In Nova that command leaves the card header and appears in that part's toolbar, running through the same command runner; untagged commands stay in the header, and the ribbon ignores the tag. - The pick pane docks beside the inspector, it does not replace it. Opening Pick from catalog used to unmount
.nova-inspector(status, totals, related documents). The column stays so the running total is visible while adding lines. Layout is form | pick | inspector;RecordWindowsmust not wrap the inspector in!goodsPick. The top of Pick is preview | groups. The preview tracks the list selection. It is not a tab stop and a click on it does not add a line. - The pick pane shows stock. With Show remainders on, the item list of the docked pick pane (
GoodsPickPane) has three figure columns: Remainder (theStockregister, balances byItemover every cell), Reserved (theReservedStockregister, read withbyAnalytics=Item,Cell) and Free (remainder minus reserved, red when negative). Figures are the warehouse total, not the order's ship-from cell: goods still sit in storage while the order names the picking cell. A column whose register does not exist in the workspace is not drawn. The arrow keys keep the selected group and item scrolled into view. - Paste from Excel maps cells by position, or by title. Pasted text (tabs, or
;/,CSV) fills the part's columns in metadata order; a first row whose cells match column captions or names maps by title instead. Numbers, dates and Yes/No are converted by column type; a reference column is matched by exact display text, ignoring case. Cells that match nothing (or several records) stay empty and are listed in a warning. No field hooks run for pasted lines; Pick from catalog runs the line hook of the picked column for each new line so price and unit fill, when the model has one. - Tabs come from metadata, and only for a card without a designed layout. In Nova, a document whose form has no saved layout (no groups, nodes, or canvas) shows its header fields as section tabs in one strip with the table parts (
Lines 4). Put a field on a named tab by addinggroup:<Name>to its metadata tags (metadataTagson the header field, in the field editor or the.object.json), for examplegroup:Deliveryorgroup:Payment and contract. Fields with nogroup:tag form the first tab, Main. The tab caption is the text aftergroup:, as typed (there is no per-language caption for it). With nogroup:tag anywhere and more than eight fields, the card splits them into Main (required fields and the first ones up to eight) and Additional; eight or fewer fields stay one list. A saved layout always wins: the designer's groups render, with no generated tabs. A Save that finds a required field empty opens the tab holding it and focuses the field. - The primary command is the first by
displayOrder.splitCommandssorts, takes the first as primary, the next one as a plain button, and the rest as More. Metadata has no "main command" flag, so a bad order gives a wrong button. - The stepper is derived, not stored.
buildStepperstarts at the initial subtype and follows the first allowed transition to a laterdisplayOrder; subtypes it does not reach are drawn as off-path pills after or. With no transition recorded at all, every subtype is a step.useStepperModelreads transitions from/api/metadata; a user without access to it (403) gets the display-order path rather than an error.
List cell skins and the progress column#
Two metadata tags on a header field (a journal) or a dictionary field (a dictionary list) change how its cell looks in the Nova list; the classic list ignores them. The value, its link and its filter stay as they were — the skin only adds a badge in front.
| Tag on the field | Effect |
|---|---|
avatar |
A 34 px rounded square with the initials of the value, then the value. |
avatar:person |
A 24 px circle with initials, then the value (for people: agents, managers). |
The badge colour is a stable hash of the text (toneFor in kit/nova/tone.ts), so one customer is one colour everywhere. Initials are the first letters of the first two words. An empty value shows no badge. The logic is kit/nova/cellSkin.ts (parseCellSkin, initialsOf); the component is kit/nova/AvatarCell.tsx.
A document journal gets one more column, Progress, next to the status, when the document type has at least three steps on its main path (hasProgress). It has no field behind it, so it is not sortable or filterable; it can be hidden in Columns. Each segment is one step of buildStepper's main path (progressSegments in kit/nova/progress.ts): passed steps green, the row's own step in the accent colour, the rest grey; the last step fills every segment, and a row off the main path (a cancelled document) stays grey.
The mocked-API harness: frontend/e2e-mock#
The Playwright specs in frontend/e2e need Core, SQL Server, and a seeded stand. frontend/e2e-mock runs the real SPA on the Vite dev server in Microsoft Edge and answers every /api/** call from fixtures (page.route). It needs neither the backend nor a browser download.
cd frontend
npx playwright test -c playwright.mock.config.ts # ribbon
$env:MOCK_SHELL = 'nova'; npx playwright test -c playwright.mock.config.ts # nova
Screens land in e2e-mock/out/{ribbon|nova}-{journal,card,palette}.png (git-ignored); the Nova run also writes nova-card-lines.png (the Lines tab), and MOCK_GROUPS=1 tags the order's header fields with group: tags and prefixes the files with nova-groups-. The harness always tags Customer with avatar and Agent with avatar:person. In a spec, mockApi(page, { shell, shellLocked, lang, groups, formLayout }) sets what GET /api/metadata/menu/my returns and fakes the session through GET /api/auth/me; formLayout: true serves a saved shared form layout, so the card renders through FormLayoutView (cards, a tab strip, a collapsible group) as on a stand with a designed form. shell: null imitates an old server that omits the field. Fixtures for the menu, metadata, and the sales-order data are in e2e-mock/fixtures.
Use it to look at a layout change and to test shell/shellLocked combinations. It is not a replacement for e2e/*.spec.ts: an endpoint without a fixture answers [] or {} and is only listed in the output (mock.unmocked), so a screen can look right against data the real API never sends. The docs screenshots still come from the docshots pipeline on a real stand — see Documentation pipeline.
Table-part totals (all layouts)#
A table part can show a totals line under its rows. Which columns are totalled is metadata, set with metadata tags on the table-part field (metadataTags, in the field editor or the .object.json); nothing is totalled by default.
| Tag on the field | Effect |
|---|---|
total |
Sum of the column. total:avg, total:min, total:max and total:count pick another function. |
currency |
The amount is money: the document's currency symbol follows it (39 663,00 ₴). A field whose EDT is named Money counts as money without the tag. |
unit:<Field> |
The column that holds the unit of this amount. The unit follows the figure, and rows in different units are never added together: 72 pcs · 30 bags. Without the tag the field's own unitFieldName is used. |
Example: total, unit:Unit on Quantity and total, currency on Amount give Total Quantity: 72 pcs · 30 bags Amount: 39 663,00 ₴.
The currency is the one the document's totals card uses (useDocumentCurrency): the header currency, else the currency of the contract or legal entity the header points at, else the common default. The line recomputes from the rows on screen, so an edit that is not saved yet is already in it. The logic is kit/totalsModel.ts (parseTotalTags, computeTotals); the component is kit/TablePartTotals.tsx, mounted under the grid in TablePartRowsGrid.
What's not there yet#
-
Totals have no editor. A tag is typed by hand; there is no checkbox for Total in the field editor, and the total is not saved with the document or sent to the server. Printed forms still take their sums from their own layout.
-
A total is over all rows on screen, not over a selection or a filter.
-
No counters in the menu. A count next to an item needs a server endpoint that counts per menu item under the caller's permissions. The numbers in a design mock are not real.
-
No per-record activity feed. The card has no history block: there is no API for the history of one record (
api.getAuditLogis the metadata log). -
The card inspector shows only real data. The right column (
.nova-inspector) holds the side-slot widgets from metadata (customer snapshot, totals, amount in words) and the related documents block ofRecordSidePanel, both as grey cards. Credit meters, stock, and an activity feed appear only when a model places a widget for them; the shell draws nothing of its own. -
Favorites, recents, and saved views are per browser. They do not follow the user to another device.
-
The journal has no pagination. Rows load as you scroll; the footer shows Loaded N of M and there is no sum over the filter, because a sum over loaded rows would mislead with a server window.
-
The calculator is a top-bar tool, not a field popover. A round Calculator icon in
NovaTopBar(with bell and help) opens the standalone pad; Ctrl+K lists Calculator in the settings group, and classic Tools runs the same command. All callopenCalculator(). Window idcalculator: a second open focuses the existing window, it does not stack copies or toggle-close. The pad evaluates one formula line with its ownevalFormula(notsafeEval;%is not modulo).CalcNumberInputon number fields is a separate popover — see Standalone calculator. -
The bell is an empty shell. Notifications opens and says there is nothing new; nothing feeds it.
-
No "main command" mark in metadata; see the trap above.
-
No group property on header fields. The tab grouping reuses the free-form metadata tags; a real
Groupcolumn (with a localized caption and an order) would need an entity field, a migration for SQL Server and PostgreSQL, and a field in the field editor. The card has no Related or History tab either: related documents stay in the side panel and history in the header button. -
Not every screen has the Nova look. The dashboard, the designers, and the admin screens still hard-code colours;
npm run audit:colorslists them. -
The default is still the ribbon.
DEFAULT_SHELLisribbon. Nova is chosen per template or per user. -
Nova Dark has no design reference. It is the stock dark surfaces with the brand accent.
-
The POS, courier, and kitchen screens are not Nova screens.
Same topic for other readers: User
Where next#
- Switch the interface layout — the user's click path.
- Assign an interface layout through an interface template — the administrator's click path and the order of rules with examples.
- Documentation pipeline — the gates and the screenshot pipeline these pages go through.
- Add a menu item — how the menu the Nova sidebar is built from is made.