Skip to content

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.

The build accepts imports only from:

  • vue, pinia, vue-router, vue-i18n;
  • approved vuetify/components and vuetify composable exports;
  • @moltaro/ui (the barrel below) and the @moltaro/workspace-ui host 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 — only useRoute. Navigate with useNavigation(), not useRouter or RouterLink.
  • pinia — only defineStore and storeToRefs.
  • vue-i18n — only useI18n (see localization below).
  • vue — reactivity, lifecycle, and defineComponent are 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.

ComponentPurpose
AppCardHeaderStandard card header with title, subtitle, eyebrow, and action row
AppEmptyStateIcon-plus-title empty or error state with an optional actions slot
ConfirmDialogConfirmation dialog before destructive or irreversible actions
DetailHeaderPanelRecord detail header: eyebrow, title, number label, busy state, actions
FormDialogDialog shell for short forms with the shared header and footer contract
MoltaroReferencePickerDialogSingle reference selection through saved Form Lookup or Generic context
MoltaroRecordHeaderNative record identity, status and authorized lifecycle menu
MoltaroRecordHistoryPaged protected record history and nested change viewers
MoltaroRecordLifecycleDialogSingle-record archive/restore/delete with a manually entered reason and exact retry
MoltaroGridBaseShared AG Grid wrapper with Moltaro column and rendering conventions
OperationalPageToolbarToolbar for operational console pages
OperationalUtilityDrawerRight-side utility drawer for filters, sorting, and columns
PropertyItemRead-only label/value fact row for detail surfaces
UtilityDrawerSectionCollapsible content section inside a utility drawer
UtilityDrawerSectionTitleTitle 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 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.

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.

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.

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 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 = null
let 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.

Rejected at build time:

  • <style> without scoped, standalone or global CSS imports, :global, and selectors rooted at html, 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 second createApp;
  • direct host globals — window, document, localStorage, sessionStorage, globalThis, self, top, parent — use useDisplay() and container queries for responsive behavior, host composables for state, and the Moltaro/external API clients instead of raw fetch.

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.

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.

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.

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>