Design system and constraints
Workspace UI pages run inside the Moltaro WebApp on the host’s own Vue, Pinia, Vuetify, vue-router, and vue-i18n instances. The design system is deliberately closed: an explicit import allowlist, a curated shared-component barrel, and a compiler that rejects any style able to escape the page. Workspace UI code is trusted workspace code compiled by Moltaro — the boundary is governance and review, not a hostile-code sandbox.
Allowed imports
Section titled “Allowed imports”The build accepts imports only from:
vue,pinia,vue-router,vue-i18n;- approved
vuetify/componentsandvuetifycomposable exports; @moltaro/ui(the barrel below) and the@moltaro/workspace-uihost SDK (pages, components, and stores);- local project-relative paths and the generated
@workspace-ui/*alias (project structure).
Everything else is a build error: Node built-ins, build-tool modules, WebApp
internals (@/*), remote URL modules, and unapproved npm packages. The
check covers static and supported dynamic import forms alike.
The framework modules are further restricted to specific named exports, so an allowed module does not mean every export is importable:
vue-router— onlyuseRoute. Navigate withuseNavigation(), notuseRouterorRouterLink.pinia— onlydefineStoreandstoreToRefs.vue-i18n— onlyuseI18n(see localization below).vue— reactivity, lifecycle, anddefineComponentare available, but rendering and app-creation APIs (h,render,createApp,Teleport,resolveDynamicComponent,cloneVNode,getCurrentInstance) are rejected; use templates and the host composables instead.
A disallowed named import fails the build with a specific WUI diagnostic that
names the offending symbol.
Shared components: @moltaro/ui
Section titled “Shared components: @moltaro/ui”| Component | Purpose |
|---|---|
AppCardHeader | Standard card header with title, subtitle, eyebrow, and action row |
AppEmptyState | Icon-plus-title empty or error state with an optional actions slot |
ConfirmDialog | Confirmation dialog before destructive or irreversible actions |
DetailHeaderPanel | Record detail header: eyebrow, title, number label, busy state, actions |
FormDialog | Dialog shell for short forms with the shared header and footer contract |
MoltaroReferencePickerDialog | Single reference selection through saved Form Lookup or Generic context |
MoltaroRecordHeader | Native record identity, status and authorized lifecycle menu |
MoltaroRecordHistory | Paged protected record history and nested change viewers |
MoltaroRecordLifecycleDialog | Single-record archive/restore/delete with a manually entered reason and exact retry |
MoltaroGridBase | Shared AG Grid wrapper with Moltaro column and rendering conventions |
OperationalPageToolbar | Toolbar for operational console pages |
OperationalUtilityDrawer | Right-side utility drawer for filters, sorting, and columns |
PropertyItem | Read-only label/value fact row for detail surfaces |
UtilityDrawerSection | Collapsible content section inside a utility drawer |
UtilityDrawerSectionTitle | Title row for a utility drawer section |
The barrel also exports the TypeScript types these components use, such as
MoltaroGridColumnDefinition and MoltaroGridRowClickedEvent. Every export
is part of the host contract; other WebApp components are unreachable.
FormDialog slots
Section titled “FormDialog slots”FormDialog renders its default slot as the dialog body. The actions slot
replaces only the default Cancel and Submit buttons and keeps the shared
right-aligned footer that wraps on narrow screens; the footer slot replaces
the whole footer. The actions slot was added together with
safe Entity writes.
On older hosts a #actions template passed to FormDialog was ignored; after
the rebuild it replaces the default buttons, so remove such a template or put
the buttons the dialog needs into it.
Grid sorting ownership
Section titled “Grid sorting ownership”MoltaroGridBase has two sorting modes. Omit activeSortState, or pass null,
to let the grid retain local sorting. Pass an array, including [], when the
page owns sorting. In that mode, handle @sort-changed (which receives the grid API) and update
activeSortState with the accepted sort; the grid restores the supplied state
until the parent changes it. Use this mode when sorting requires a new server
query. Check the host version when integrating with an older installation.
Page layouts
Section titled “Page layouts”Each manifest page declares one host-owned Layout; a missing or unknown
value is a build error.
- Default — the normal document layout: the Moltaro navigation shell plus a container that grows with content and scrolls at document level. Use it for forms, detail pages, reports, and vertically flowing surfaces.
- Fullscreen — the Data Explorer-style application layout: the same shell plus a root that fills the content viewport with no document-level scrolling; the page owns its internal scroll areas. It never hides Moltaro navigation and never calls the browser Fullscreen API.
Both layouts expose the same Vue/Vuetify context; responsive behavior stays
page-owned via useDisplay(), breakpoint props, and media or container
queries. A page can read its resolved layout but cannot replace the shell.
Styling rules
Section titled “Styling rules”Pages may use <style scoped> blocks and static style/:style bindings:
colors, typography, spacing, custom properties, flex/grid, media/container
queries, animations, transitions, and pseudo-classes/elements — alongside
Vuetify theme tokens, props, and utility classes. Inline style/:style is
accepted only on native HTML elements; to style a Vuetify component use its
props, classes, and theme tokens, or wrap it in a native element.
The compiler namespaces every style to its page: a deterministic opaque
scope id is rendered as a data-workspace-ui-scope attribute, every
compiled selector is rewritten to match only inside that scope, and keyframe
names are namespaced. The host provides a page-owned overlay target through
usePageContext().overlayTarget. Pass it explicitly as attach to dialogs and
menus that must receive the page’s scoped styles. The target carries
data-workspace-ui-overlay-scope; compiled selectors cover both the page root
and this overlay scope. Providing the target does not automatically attach every
Vuetify overlay to it.
<script setup lang="ts"> import { usePageContext } from '@moltaro/workspace-ui' import { ref } from 'vue' import { VDialog } from 'vuetify/components'
const pageContext = usePageContext() const open = ref(false)</script>
<template> <button type="button"> Open example <VDialog v-model="open" activator="parent" aria-label="Example" :attach="pageContext.overlayTarget" max-width="480" > <section class="example-panel"> <h2>Example</h2> <button type="button" @click="open = false">Close</button> </section> </VDialog> </button></template>
<style scoped>.example-panel { padding: 24px; background: rgb(var(--v-theme-surface));}</style>Apply the same explicit target to nested overlays and to shared components that
expose attach, including FormDialog, ConfirmDialog, and DetailHeaderPanel.
An overlay attached to the document body is outside the page’s CSS scope.
Attachment preserves containment; it does not make a shared component’s internal
markup part of your Vue SFC scope. Author styles apply to authored elements and
slot content, as in the dialog above or the body/footer of FormDialog.
ConfirmDialog exposes title/message/error/action props, not a custom content
slot or a root-styling contract. A class passed to its teleported root does not
gain the author’s data-v-* attribute; :deep() still needs an authored scope
ancestor. Use its supported props, or authored dialog content for custom visuals.
Keep the compiler’s scope guards and the global CSS prohibition intact.
Here activator="parent" lets Vuetify attach its trigger and focus-return
behavior to the enclosing button. General v-bind="props" and dynamic argument
bindings are rejected by the authoring policy; this example needs neither.
Import each Vuetify component used by the source from vuetify/components.
The project compiler does not run the WebApp’s Vuetify auto-import plugin.
For example, <v-btn> requires an imported VBtn; an unresolved custom HTML
element can respond to a pointer click while lacking button keyboard/focus behavior.
For a select inside a dialog, attach its menu separately:
<VSelect v-model="selectedId" :items="options" :label="t('fields.reference')" :menu-props="{ attach: pageContext.overlayTarget }"/>The page starter includes a read-only record preview in FormDialog. Generate
it through template preview/apply; generation does not update existing pages.
Use cancel-label for its close label and the footer slot for read-only
actions. ConfirmDialog emits cancel/confirm; the caller closes it by
updating its model after handling the action. Do not close a confirmation on
an API error: keep its error visible and allow a retry.
After opening each nested overlay, check that its authored content is below
the current page’s data-workspace-ui-overlay-scope and that its scoped rule
actually matches. Close the innermost overlay first with Escape, verify focus
returns to its launcher, then navigate away with a dialog open. The previous
page must leave no active modal or scroll lock. Shared host tooltip appearance
is owned by the shared component; page styling is not a global theme override.
Local and server sorting examples
Section titled “Local and server sorting examples”Local mode sorts the complete rowData array in the browser. It does not fetch
or sort records from other server pages. Mark supported columns sortable and
omit active-sort-state (or pass null):
<MoltaroGridBase :column-defs="columns" dom-layout="autoHeight" :row-data="allRows"/>For a server page, initialize activeSortState to [] and treat it as the last
accepted sort. The sort-changed argument is a MoltaroGridSortChangedEvent.
Read getColumnState() synchronously in that handler: the grid restores
the accepted prop on the next tick. The supported structural fields are
colId, sort (asc, desc, or empty), and sortIndex. Reading the event
later can observe the restored state instead of the user’s request.
This handler captures a single allowed column; multi-column implementations
use an explicit column-to-target map and preserve sortIndex order:
import type { MoltaroGridActiveSortState, MoltaroGridSortChangedEvent } from '@moltaro/ui'
function sortRequested (event: MoltaroGridSortChangedEvent) { const intent: MoltaroGridActiveSortState[] = event.getColumnState() .filter(column => column.colId === 'DisplayName' && (column.sort === 'asc' || column.sort === 'desc')) .sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0)) .map((column, index) => ({ colId: column.colId, sort: column.sort as 'asc' | 'desc', sortIndex: index })) void load(intent)}load owns the HTTP request and accepted result. The example below uses the
anchored Form candidate API. Supply a
saved Form item’s candidateRoute, current formRevision, allowed
CurrentValues, and the target entity’s configured titleFieldId. This example
assumes that title is the only sortable display column and has no draft
dependencies; it deliberately supplies an empty CurrentValues object.
Do not infer eligibility from a generic picker or send column IDs as field IDs.
import { ApiClientError, MoltaroApiClient } from '@moltaro/workspace-ui'import { onBeforeUnmount, ref } from 'vue'
type Row = { Id: string, DisplayName: string }const api = new MoltaroApiClient()const rows = ref<Row[]>([])const accepted = ref<MoltaroGridActiveSortState[]>([])const pending = ref(false)const error = ref('')let requested: MoltaroGridActiveSortState[] = []let controller: AbortController | null = nulllet generation = 0
async function load (intent: MoltaroGridActiveSortState[]) { requested = intent.map(item => ({ ...item })) const current = ++generation controller?.abort() const requestController = new AbortController() controller = requestController pending.value = true error.value = '' try { const result = await api.requestData<{ State: number, Items: Row[] }>(candidateRoute, { method: 'POST', signal: requestController.signal, body: JSON.stringify({ ExpectedFormSurfaceRowVersion: formRevision, CurrentValues: {}, Query: { Page: 1, PageSize: 25, Sort: intent.map(item => ({ Target: { Path: [{ FieldId: titleFieldId }] }, Direction: item.sort === 'asc' ? 0 : 1, })), }, }), }) if (current !== generation) return if (result.State !== 0) throw new Error('Candidates unavailable') rows.value = result.Items accepted.value = intent.map(item => ({ ...item })) } catch (error_) { if (current !== generation || requestController.signal.aborted) return error.value = error_ instanceof ApiClientError ? error_.errors.map(item => item.Message).join(' ') || t('errors.loadFailed') : t('errors.loadFailed') } finally { if (current === generation) pending.value = false }}onBeforeUnmount(() => { generation++ controller?.abort()})Bind rows and accepted together. Display pending in a status region and
error in an alert; a retry calls load(requested), and clear calls load([]).
Perform the initial request with load([]) on mount. Keep the last accepted
rows and sort when a request fails. Do not omit the prop while pending: that
would switch ownership back to local mode.
<MoltaroGridBase :active-sort-state="accepted" :column-defs="columns" dom-layout="autoHeight" :row-data="rows" @sort-changed="sortRequested"/>An empty accepted sort clears explicit criteria; the server still determines
its default ordering. Updating activeSortState does not make requests by
itself. Returning to null transfers sorting to the local grid.
The getColumnState() declaration requires a build containing this authoring
contract. After updating Moltaro, download current IDE declarations and run
Check → Build → Activate, then fully refresh the WebApp. Host contract version
1 is unchanged, but the fingerprint changes; an old artifact is not evidence
that the new contract is installed.
Build restrictions
Section titled “Build restrictions”Rejected at build time:
<style>withoutscoped, standalone or global CSS imports,:global, and selectors rooted athtml,body,:root,#app, or shell elements;@import, remote stylesheets or scripts,@font-face, and custom fonts;v-html, runtime template compilation, dynamic<component>resolution, runtime style or stylesheet injection, and a secondcreateApp;- direct host globals —
window,document,localStorage,sessionStorage,globalThis,self,top,parent— useuseDisplay()and container queries for responsive behavior, host composables for state, and the Moltaro/external API clients instead of rawfetch.
Framework and toolchain ownership
Section titled “Framework and toolchain ownership”Moltaro owns the framework and toolchain versions. Builds run against an offline toolchain packaged with the installation; there are no arbitrary npm dependencies or third-party build plugins. After a Moltaro update a rebuild may be required, and a breaking host-contract change may require source edits — see Build, publish, and upgrade.
Each build also runs an ESLint quality gate based on eslint-config-vuetify,
and lint errors fail the build. Author source to those conventions — attribute
ordering, self-closing components, and import ordering — so a functionally
correct page also passes the gate; every diagnostic carries the rule id that
flagged it.
Theming
Section titled “Theming”useMoltaroContext().theme exposes a computed { name, isDark }, and
Vuetify tokens such as rgb(var(--v-theme-primary)) resolve against the
active workspace theme, so scoped styles stay correct in light and dark.
Localization and accessibility
Section titled “Localization and accessibility”Project source owns locale JSON for en, uk, de, pl, and es; the
build requires matching key sets, messages register under a project
namespace, and the active WebApp locale selects the page locale. Render text
through useI18n() — call it with no arguments and destructure only t, te,
and tm (the $t template global and the options/scope overloads are
rejected). Read the active locale from useMoltaroContext().locale or
useFormatting().locale, not from useI18n(). Keep Vuetify inputs labeled and
page roots semantic, and give interactive elements stable data-testid values.
Example: generated Entity details page
Section titled “Example: generated Entity details page”A trimmed excerpt of the Entity details page produced by the UI Studio generator:
<script setup lang="ts">import { AppCardHeader, DetailHeaderPanel, PropertyItem } from '@moltaro/ui'import { useOrderDetailsStore } from '@workspace-ui/stores/orderDetailsStore'import { VCard, VCardText } from 'vuetify/components'
const store = useOrderDetailsStore()</script><template> <main data-testid="order-details-page"> <DetailHeaderPanel :busy="store.loading" eyebrow="Order" :number-label="store.record?.Number" :title="store.record?.DisplayName" /> <v-card v-for="group in groups" :key="group.key" border variant="flat"> <AppCardHeader density="compact" :title="group.title" /> <v-card-text> <PropertyItem v-for="field in group.fields" :key="field.key" :label="field.label" :value="displayValue(field)" /> </v-card-text> </v-card> </main></template>