Page context
These composables let your layer's Vue read where it sits in the dashboard — the navigation tree, the current module, whether the viewer owns the tenant — and persist per-user UX preferences. The state-bearing ones are shared, SSR-safe singletons, so every component sees the same values.
Layout & module context
The dashboard fetches its navigation and module map once into shared state; these composables expose that state and derive the current position from the route.
useSiteLayout()
useSiteLayout exposes the dashboard's loaded structure as a bag of shared reactive refs. Layer authors read these — most often modules, isOwner, and quickActions:
| Ref | Type | Holds |
|---|---|---|
siteLayout | SiteLayout | undefined | pages by slug, categories by full id |
siteLayoutTree | SiteLayoutTree | undefined | the nested nav tree (children + ordering) |
quickActions | QuickActionsPayload | undefined | { categories, actions } for the command palette |
modules | Record<string, ModuleInfo & { hasAccess: boolean }> | undefined | every registered module by id |
isOwner | boolean | viewer owns the current tenant |
pageLayouts | Record<string, PageLayout> | per-slug component-layout cache |
isLoading | boolean | a fetch is in flight |
loadingError | string | null | last load error message |
siteLayout.categories lists every category once, by fullId; a page or a
category names its own by categoryId rather than carrying a copy of it. A
category declared with urlSlug: "/" adds no segment of its own — it is a
section heading, not a route — so it is urlTransparent, and a URL never
resolves to it. The menu carries no counted badge: each page visit brings
those (navBadges), and the navigation draws them.
const { modules, isOwner, quickActions } = useSiteLayout();
const myModule = computed(() => modules.value?.["my-module"]);
loadSiteLayout, loadPageLayout, refresh, refreshIfStale, findMatchingRoute, findMatchingRouteOrCategory, and the dev-only probeAndCommitRoute. These are internal framework plumbing (the page renderer, middleware, and dev hot-reload plugin call them). Treat the refs above as your read surface and leave the methods alone.A page the backend describes brings the site layout with its props. A page only a frontend layer knows (a custom page) does not: the dashboard frame reads it with the page then (useRenderedSiteLayout()), so the menu is rendered on the server there too.useCurrentModule()
() → ComputedRef<{ id, info } | null> — the module owning the current route, derived from a /modules/<id>/… path and looked up in modules. Returns null off any module page. info is the ModuleInfo & { hasAccess } entry.
const current = useCurrentModule(); // current.value?.id
useIsOwner()
() → ComputedRef<boolean> — convenience wrapper over useSiteLayout().isOwner; true only when the viewer owns the tenant. Use it to gate owner-only UI. See Auth & permissions.
useModulesListing()
() → { modules, isLoading, loadingError, loadModulesListing, refresh } — the platform owner's read of /dms/modules-listing, with the page (a useApiData read: a server render shows it and hands it to the browser, which does not read it again; nothing is read for anyone else). modules is a ModuleCatalogEntry[] (undefined until loaded): each module's info with its landingSlug, plus the status and readout lines its catalog hooks reported. loadModulesListing() waits for it; refresh() reads it again on request, keeping the current list on screen while loading. A failure is not thrown: loadingError is a ModulesListingError, { kind: "forbidden" | "failed", status? } — forbidden when the caller is not an owner (a 403), failed for anything else, with the HTTP status when the server answered — and modules is then undefined. For the management/marketplace surface, not per-page rendering.
useModuleHistory()
() → { record(moduleId, path), getLast(moduleId), entries } — remembers the last path visited inside each module, for the Modules page's "recently used" order and the command palette's "recent modules" group. It never decides where entering a module lands: a module tile, a breadcrumb, a recent module in the command palette or the module's own address always opens its landing page. The history is kept in the dms-module-history cookie (a year, the twelve most recent modules), so it survives reloads and the server renders the same "recently used" order as the browser. getLast returns the stored path or null; entries is a computed list of ModuleHistoryEntry — { moduleId, path, visitedAt }, visitedAt being the epoch milliseconds of the last visit (0 when unknown) — ordered oldest to newest visit. Recording an already-known module moves it to the end, so the ordering reflects true recency (the command palette's "recent modules" group reads it reversed).
const history = useModuleHistory();
history.record("crm", "/modules/crm/contacts/42");
const back = history.getLast("crm"); // "/modules/crm/contacts/42"
const recent = history.entries.value; // [{ moduleId: "crm", path: "…", visitedAt: 1767225600000 }, …]
useQuickActions()
() → { categoryGroups: ComputedRef<QuickActionCategoryGroup[]> } — the quick actions of the current user (from useSiteLayout().quickActions), grouped by category and sorted by order on both levels. The payload only ever carries actions the caller can run — the server leaves the others out — so there is no access flag to filter on. It is the shared source behind every quick-action surface, the header popover and the command palette, so read it instead of re-deriving groups from the raw payload. The module also exports dispatchQuickActionTarget(target), which runs a quick action's client-side behavior: navigate, openForm and button route to the action's page (openForm and button carrying the intent in the query, which the table view picks up when it mounts — opening its creation form, or pressing the named custom button), and event dispatches a window CustomEvent in place.
const { categoryGroups } = useQuickActions();
// categoryGroups.value → [{ category, actions }, …], permission-filtered
dispatchQuickActionTarget(action.target); // run one
usePageRecordLabel()
() → { label, setLabel(path, value) } — the label of the record a page is about, which the page header puts at the end of its breadcrumb ("Tasks › Fix the login page"). A Form with labelKey sets it once it loaded its record; a module's own component on a record page sets it the same way, from the record it shows, and any component may read it.
const route = useDmsRoute();
const { label, setLabel } = usePageRecordLabel();
watch(openedRoute, (name) => setLabel(route.path, name), { immediate: true });
| Member | Type | Contract |
|---|---|---|
label | ComputedRef<string | undefined> | The label set for the page on screen, undefined when none was. |
setLabel | (path: string, value: string | undefined) → void | Names the record of the page at path (useDmsRoute().path for the current one); undefined or "" clears it. |
The label is held with the path it was set for, so it never outlives its page: once the user leaves, label reads undefined until the next page sets its own. One label per page: a form and a component of the same page setting it, the last one wins.
usePageToolbarSize()
() → { size, heightClass } — the size of the PageToolbar a component sits in, for a leading block of a module's own (a scope chip, a picker) to stand as tall as the live toggle and the period beside it.
<script setup lang="ts">
const { heightClass } = usePageToolbarSize();
</script>
<template>
<span class="inline-flex items-center rounded-md border px-2" :class="heightClass">
All sites
</span>
</template>
| Member | Type | Contract |
|---|---|---|
size | ComputedRef<"xs" | "sm" | "md"> | The toolbar's size; sm outside a toolbar. |
heightClass | ComputedRef<string> | The height every control of the toolbar takes at that size: h-6, h-7, h-8. |
Waiting for a backend change
useDevReload()
A module that writes to the backend — a no-code builder creating a page, an AI chatbox editing one — makes the backend module hot-reload and re-register its pages one at a time. For a moment the site layout does not serve the page the user is on, nor the one you are about to navigate to. The core already absorbs that: it re-fetches the layout, commits it only once the route resolves in it, and holds back the 404 the renderer would otherwise raise. Nothing is shown while this happens — the current page stays mounted and the new layout is applied in place, so the add/remove/update component animations play as usual.
useDevReload() is the handle on that mechanism. Do not poll the site layout yourself: a second loop races the core's and re-fetches the layout for nothing.
const { awaitRoute } = useDevReload();
await createPage(path); // your own backend call
await awaitRoute(path); // the layout now serves it
await navigateDms(path); // no 404 flash
| Member | Type | Contract |
|---|---|---|
reloading | Readonly<Ref<boolean>> | A reload is in flight. Internal state — never render it, and never gate UI on it; a reload must stay invisible. |
settled() | () → Promise<void> | Resolves once no refresh is in flight. Use it before reading layout-derived state you need to be current. |
awaitRoute(path, options) | (string, { timeoutMs?: number }) → Promise<boolean> | Resolves true once the committed site layout serves path, false on timeout (default 15000 ms). |
awaitRoute returns immediately when the layout already serves path. Otherwise it joins the refresh the core is already running rather than starting its own, and only drives one if there is none. Calls coalesce: however many modules ask at once, the layout is fetched by a single loop.
false is a timeout, not an error — the page may still exist and arrive a moment later. Navigate anyway if that is the sensible fallback; the renderer resolves the route again once the layout lands.awaitRoute resolves true when the route is already known and otherwise waits briefly on the committed layout before answering false; it never fetches. Write the call once and it behaves correctly in both builds.Page setup functions (setupId)
A backend page can name a setup function in its setupId option (Pages & components). The renderer resolves that id from the function registry when the page mounts, so your layer registers the handler once, from a plugin:
export default defineDmsPlugin(() => {
const { registerFunction } = useDefinedFunctions();
registerFunction("app.pages.DashboardSetup", (context: PageSetupContext) => {
// React to any component on the page:
const off = context.on("form", FormEvents.FIELD_CHANGE, (data) => {
context.emit("chart", "app.chart.Highlight", data);
});
return () => off(); // cleanup — runs on page unmount
});
});
PageSetupContext carries pageInfo (the resolved page), permissions (the viewer's set), and the on / emit pair — a page-scoped view of the Component events. on returns an unsubscribe function; the setup function may return a cleanup callback (or a promise of one), which the renderer calls on unmount.
Use a setup function when a page needs orchestration across components but no custom rendering — it keeps the page fully backend-described.
siblingComponentId(componentId, key) — the block next to yours
A custom block that follows another block of its page (a live preview reading the form beside it) needs that block's component id, the one its component events carry. siblingComponentId names the block key declared under the same parent as the block componentId, a top-level block's neighbour as well as one inside a Grid or a GridRow:
const props = defineProps<{ componentId: string }>();
const formId = siblingComponentId(props.componentId, "form");
function onFieldChange(event: Event) {
const { component, data } = (event as CustomEvent).detail ?? {};
if (component !== formId) return;
// data.formValues: the form's values as typed
}
onMounted(() => window.addEventListener(FormEvents.FIELD_CHANGE, onFieldChange));
onBeforeUnmount(() =>
window.removeEventListener(FormEvents.FIELD_CHANGE, onFieldChange),
);
Ask for it rather than editing the id: how a nested block's id is built is the renderer's to change.
User preferences & UX
These composables persist per-user settings in cookies, so they survive reloads and are available during SSR.
usePreferences()
usePreferences is a cookie-backed key/value store (dms-preferences cookie, one-year max-age) addressed by dotted paths. It returns:
| Member | Purpose |
|---|---|
preferences | readonly snapshot of the whole tree |
getPreference(keyPath, default?) | read a nested value, falling back to default |
setPreference(keyPath, value) | write a value, creating intermediate objects |
updatePreference(keyPath, partial) | shallow-merge into an existing object value |
removePreference(keyPath) | delete a key |
clearAllPreferences() | reset to {}, and drop the table views' browser-only state |
const { getPreference, setPreference } = usePreferences();
setPreference("my-module.listSort", { field: "name", desc: false });
const sort = getPreference("my-module.listSort", {
field: "createdAt",
desc: true,
});
The tree is stored as base64url JSON, so the server render reads the same preferences as the browser. The cookie is sent with every request, so it is bounded: once it would exceed MAX_PREFERENCES_COOKIE_BYTES (3 KB, name and value together), the table view states written least recently (the entries under tables) are dropped until it fits, then the remembered periods (periods). A write that would not fit even then is refused (with a warning in development) and leaves the stored preferences as they were. Anything outside tables and periods is never evicted, so keep a module's own preferences small and under a key of its own; bulky or unbounded state that a server render does not need belongs in localStorage.
A table view keeps its state in two places:
- In the cookie, what the server render needs to list and draw the right rows: the sort, the column filters, the page and page size, the active tab, the display, the open view and the hidden columns. Each table has one entry,
tables.<id>, where<id>is an 8-character hash of the component id and the page id. A value equal to the table's default is not written, and the values are compact (a sort is"-name", a hidden column"-email"), so a sorted table costs about 35 bytes, and a sorted, filtered, paged table with two hidden columns about 110: the cookie holds more than 20 of them. - In
localStorage(dms-table-preferences:v1:<id>), what only the browser draws: the column order and widths, the density, whether the filters row is open, the kanban and grouped group-by, and the user's saved views. The server and the hydrating browser render the defaults, and the stored values apply once the table is mounted.
These entries are internal: read or write a table's state through the table view, not through tables.* paths.
Up to @antelopejs/dms 0.7.9 the preferences lived in a user-preferences cookie that grew with every table opened, until the server refused requests with a 431. It is not read: the browser deletes it the first time usePreferences runs, and the preferences it held start over.
useInterfaceScale()
() → CookieRef<InterfaceScale> — UI density as a writable cookie ref (dms-interface-scale, values "small" | "normal" | "large", default "normal"). Assign to .value to change it.
useColorModePreference()
() → CookieRef<ColorModePreference> — the color mode as a writable cookie ref (dms-color-mode, values "light" | "dark" | "system", default "system"). Assign to .value to change it; Nuxt UI's color-mode components follow, and a change made through them updates the cookie.
useAccessibilityPreferences()
() → { preferences, reduceMotion, increaseContrast, underlineLinks } — the accessibility options of the Appearance page, from the dms-accessibility cookie (URI-encoded JSON, kept a year). preferences is the complete, read-only object; the three others are writable refs: reduceMotion is "auto" | "on" | "off" (default "auto", which follows prefers-reduced-motion), increaseContrast and underlineLinks are booleans (default false). Assigning one rewrites the cookie and updates the <html> classes (Theming).
useUserRegionalPreferences()
() → { preferences, detectedTimeZone, timeZone, weekStartsOn, timeFormat } — the signed-in user's choices from Settings › Language & region. Unlike the cookie-backed composables above, these are stored on the user and follow them on every device. preferences holds what the user chose (timeZone, weekStart, timeFormat, dateFormat; an unset field is automatic); timeZone, weekStartsOn (0 Sunday … 6 Saturday) and timeFormat ("h23" | "h12") are computed refs with the automatic ones resolved — the browser's zone (detectedTimeZone), the language's first day and clock. The shared date formatters already apply them (Localization); reach for this for a component of your own, such as a calendar:
<script setup lang="ts">
import { useUserRegionalPreferences } from "#dms-core/app/composables/user/useUserRegionalPreferences";
const { weekStartsOn } = useUserRegionalPreferences();
</script>
<template>
<UCalendar :week-starts-on="weekStartsOn" />
</template>
useRegionalFormat()
(options?: { locale?: "interface" | "format" }) → { locale, formatLocale, dateTimeFormat, formatDate, formatTime, formatDateTime, formatRelativeTime, formatNumber, formatPrice, formatPercentage } — the shared formatters bound to the interface language and the user's Language & region preferences (time zone, clock, date format). Each call reads the current language, so a value formatted in a computed follows a language switch. dateTimeFormat(options, settings?) returns an Intl.DateTimeFormat for a format the helpers do not cover; pass { keepLocalZone: true } for a calendar day computed in the browser. formatRelativeTime(date) writes "5 minutes ago", "yesterday at 14:05", then the date (Localization). formatNumber, formatPrice and formatPercentage return a string for a number (RegionalNumberFormatter), and any other value as it is.
locale is the interface language (en, fr), which the formatters write in by default, as the DMS's own blocks do. formatLocale is the locale the DMS's texts are written in for it — en is en-GB (24-hour clock, day before month), fr is fr-FR (regionalFormatLocale(locale) in dms-core/app/utils/regional outside a component). A module whose screens read in the convention of those texts asks for it once: useRegionalFormat({ locale: "format" }) writes every value, dateTimeFormat included, in formatLocale.
<script setup lang="ts">
interface OrderRow {
createdAt: string;
total: number;
}
const props = defineProps<{ row: OrderRow }>();
const { formatDateTime, formatPrice } = useRegionalFormat();
</script>
<template>
<span>{{ formatDateTime(props.row.createdAt) }} · {{ formatPrice(props.row.total) }}</span>
</template>
useFavoritePages()
useFavoritePages tracks the user's pinned pages, persisted to the dms-favorite-pages cookie and mirrored in shared state. A FavoritePage records the page's id, path, title, and optional icon. It returns:
| Member | Purpose |
|---|---|
favorites / sortedFavorites | raw list / title-sorted computed list |
isFavorite(path) | is this path pinned |
toggleFavorite(page) | add/remove, returns the new pinned state |
addFavorite(page) / removeFavorite(path) | explicit add / remove |
clearFavorites() | remove all |
cleanupInvalidFavorites(isValidPath) | drop entries failing the predicate |
const { isFavorite, toggleFavorite } = useFavoritePages();
toggleFavorite({ id: "p1", path: route.path, title: "Contacts", icon: "i-ph-users" });
App config & system
useHomepage()
() → string — the dashboard's home path, from the homepage config key (Configuration), defaulting to "/". Use it for "back to home" links instead of hard-coding /.
useFirstAccessiblePagePath()
() → string | undefined — the path of the first page the current user can access, in tree (sidebar) order. Categories the user cannot access are still descended into (they may hold accessible pages), but only navigable, non-hidden pages the user can access are returned; settings pages are a last resort and module pages are never chosen (they have their own routing). The page renderer uses it to redirect a user whose role cannot access the configured homepage; undefined when the site layout is not loaded or no page is accessible. A pure firstAccessiblePagePath(tree) variant takes the layout tree directly, for use after an await where composable context is unavailable.
useSystemState()
Exposes the instance's system state, fetched server-side so it is already available during SSR. It returns:
systemState— thehas_onboardedflag (what the onboarding gating reads) and the dashboardmeta(title,description) from the backend config.metaTitle/metaDescription— computed conveniences for a page's<head>.error/refresh()— the fetch error, and a refetch to call after a state change.
Realtime & notifications
Consume SSE topic updates through the realtime composables and the notification-center store they feed.
UI toolkit
The helpers a frontend layer builds screens with — programmatic drawers and modals, confirm dialogs, error toasts, export-job progress, page header actions, the record card, the required-fields legend, the code snippet, scrolling to a block, and shortcut declarations.