[data-reveal]{opacity:1!important;transform:none!important}
Utilities
Frontend composables

Utilities

i18n key resolution, composed texts, unsaved-changes leave guarding, onboarding state and hydration checks for layer authors.

The remaining composables round out a frontend layer's toolkit; each section leads with the one to reach for.

i18n

useTranslation — the $-prefix convention

Backend-driven screens pass strings that may be either a literal ("Save") or a translation key. The DMS disambiguates with a leading $: a value beginning with $ is an i18n key, anything else is shown verbatim. processI18n applies that rule:

resolving a label
const { processI18n } = useTranslation();

processI18n("Save"); // → "Save" (literal, returned as-is)
processI18n("$actions.save"); // → t("actions.save") — the leading $ is stripped
processI18n("$items.count", { n: 3 }); // params forwarded to t()

A custom builder can therefore accept user-authored labels and translation keys through the same prop — only the $-prefixed ones get looked up. Define the matching keys in your layer's locale files (Localization).

Outside a component setup — a DMS plugin, a data-type formatter, a render function — useI18n() is unavailable: use getAppTranslation(), the one way to the application's translations there. It returns the same processI18n and processApiMessage, plus the composer's own t for a key without $, and reads the active locale on every call, so a text written in a computed follows a language switch. This is how command-palette sources translate backend display names:

app/plugins/my-source.client.ts
const { processI18n, t } = getAppTranslation();
processI18n("$actions.save"); // → t("actions.save")
processI18n("Save"); // → "Save"
t("commandPalette.groups.navigation"); // a plain key

useComposedText — texts composed in the reader's language

A block text from the backend is a plain string or a composed text — { key, params }, with raw amounts, dates and counts. processText writes either one: it applies the $ rule to a string, writes a number for the locale, and composes a ComposedText with each parameter formatted for the active locale and the user's regional preferences, the plural form picked by its count. It reads the active locale on every call, so in a computed it follows a language switch.

a custom cell or card
const { processText } = useComposedText();

const line = computed(() => processText(props.row.statusDetail));
// { key: "saas.invoice.retry", params: { date: { type: "date", value: "2026-10-10", format: "day" } } }
// → "Payment failed, retry Oct 10"
processText("$actions.save"); // → t("actions.save"), like processI18n

Outside a component, resolveComposedText(text, { translate, locale }) does the same with a vue-i18n t taking (key, named, plural?), and isComposedText(value) tells a composed text from a string. The types (ComposedText, BlockText, the parameter types) are mirrored in the dms-core layer.

useUniqueLocales — for language switchers

useI18n().locales can list the same language code more than once. useUniqueLocales returns a computed deduplicated by code (first occurrence wins) — use it to populate a locale picker.

locale picker
const { uniqueLocales } = useUniqueLocales();
// uniqueLocales.value → one entry per code

Leave guard

useUnsavedChanges and useFormDirty — guard a hand-built form

The generic Form already guards its unsaved changes (see Forms). A form you build yourself gets the same behaviour, and the same warning dialog, from two composables of the UI layer:

  • useFormDirty(values, { initial? }) tracks the dirty state: dirty is true while values() differs, deeply and by value, from the starting values (a snapshot taken when called, or the initial() you keep yourself). Empty values (undefined, null, "", [], false) count as the same. markClean() makes the current values the new starting point, after a save.
  • useUnsavedChanges({ dirty, containerId?, element? }) asks before the changes are lost: leaving the page (links, the router, the browser's Back and Forward buttons, a reload or a closed tab), closing the drawer or modal named by containerId, or switching away from the tab panel holding element. It returns confirmLeave(), to ask before your own way out (a Cancel button, another entry picked in an editor). One dialog at most: a leave asked while it is open waits for the same answer, and discarding releases the form until it is clean again.
hand-built form
import { useFormDirty } from "#dms-ui/app/composables/unsaved-changes/useFormDirty";
import { useUnsavedChanges } from "#dms-ui/app/composables/unsaved-changes/useUnsavedChanges";

const name = ref(saved.value.name);
const { dirty } = useFormDirty(() => ({ name: name.value }), {
  initial: () => ({ name: saved.value.name }),
});
const { confirmLeave } = useUnsavedChanges({ dirty });

async function cancel() {
  if (await confirmLeave()) closePanel();
}

Show DmsSaveBar with :dirty="dirty" for the save bar: it takes no space while hidden, floats over the bottom of the page while the form runs past it, and makes room under the last field when it shows there.

usePageLeaveGuard — block navigation for another reason

The ergonomic entry point. It is page-scoped: guards are keyed by the current route and cleared automatically on unmount, so you register and forget.

registerGuard(callback) returns an unregister function. A callback that returns false (or throws) blocks navigation; returning anything else allows it. Callbacks may be async — guards are awaited.

unsaved form
const { registerGuard } = usePageLeaveGuard();
const { confirm } = useConfirm();
const isDirty = ref(false);

onMounted(() => {
  registerGuard(async () => {
    if (!isDirty.value) return true; // clean → allow
    return confirm({
      title: "Unsaved changes",
      description: "Discard them and leave?",
    }); // false → stay
  });
});

The dialog comes from useConfirm (UI toolkit), so the prompt matches the rest of the dashboard.

Guards only run client-side; during SSR registration is a no-op.

useLeaveGuard — the multi-guard primitive (advanced)

usePageLeaveGuard is a thin wrapper over useLeaveGuard, the lower-level registry. Reach for it only when guards must be scoped to something other than the current page — a multi-step container that outlives the route, for example. It exposes:

  • addGuard(containerId, callback) → returns an unregister function.
  • removeGuard(containerId, guardId) — remove a single guard.
  • executeGuards(containerId) → Promise<boolean> — runs the container's guards in registration order; false if any returns false or throws.
  • clearGuards(containerId) — drop all guards for a container.

You own the containerId namespace and the cleanup.

Onboarding

useOnboarding — read onboarding state

useOnboarding() is async and resolves to a hasOnboarded flag. The first call fetches the state from the backend as async data under the dms-onboarding key; subsequent calls return immediately. A server render hands it to the browser with the page, so the browser does not ask again.

read state
const { hasOnboarded } = await useOnboarding();

setOnboardingComplete — release the guard without a reload

The backend flips the onboarding flag when the first admin registers, but the cached client value stays stale until a full reload. Call setOnboardingComplete() to set the cached flag to true, so the global onboarding guard lets the user leave the wizard immediately — wire it to a wizard "finish" button.

wizard finish
async function finish() {
  // ...persist the wizard's result via your own backend route ($authFetch)...
  setOnboardingComplete(); // cached flag → true; no reload needed
  await navigateDms("/");
}
The funneling itself — non-onboarded users are redirected to /onboarding, and the wizard keeps already-onboarded users out — is built-in route middleware; you configure nothing for it.

Hydration

isDmsHydrating — draw what the server drew

A page the server renders is taken over by the browser: Vue hydrates the server's HTML, and every text, class and node must come out the same, or Vue warns Hydration ... mismatch and redraws. isDmsHydrating() tells whether the browser is doing that now: it is true from the start of the application to the tick the page has hydrated, and false on the server, in a page the server did not render, and after a client navigation.

It is for what the server and the browser cannot compute alike: a part drawn in the browser only (a canvas, an iframe preview) or a value only the browser has (local storage, the clock). While hydrating, a component draws the server's placeholder until it is mounted; after a client navigation it draws the real thing from its first render, with no placeholder flashing first:

app/components/SitePreview.vue
<script setup lang="ts">
// Hydrating: the server drew the placeholder, so the browser's first render
// draws it too. Anywhere else: the preview at once.
const isDrawn = ref(!import.meta.env.SSR && !isDmsHydrating());
onMounted(() => {
  isDrawn.value = true;
});
</script>

Data needs no check: load it with useApiData, which a server render waits for and hands to the browser, without awaiting it in the setup (Authenticated requests).