Blocks

App Navigation Builder Block

Working API

A complete navigation operations section for editing hierarchy, checking real routes and policies, previewing audiences, and publishing immutable app-shell versions.

Navigation

App navigation builder

Copy this into admin settings, CMS tooling, customer portals, app builders, or internal platforms that let teams manage nested product navigation.

1200px

Install
npm install @getdom/studio
vue
<script setup>
import '@getdom/studio/style.css';
import { computed, onMounted, ref, watch } from 'vue';
import {
	DomAlert,
	DomAppShell,
	DomAppTopBar,
	DomAvatar,
	DomButton,
	DomDialog,
	DomDrawer,
	DomIconButton,
	DomIconSelector,
	DomProfileMenu,
	DomSelect,
	DomSkeleton,
	DomStatusPill,
	DomTagCombobox,
	DomTextInput,
	DomTextareaInput,
	DomToggle,
} from '@getdom/studio';
import NavigationInspector from '../components/NavigationInspector.vue';
import NavigationTreePanel from '../components/NavigationTreePanel.vue';
import PreviewMenuItem from '../components/PreviewMenuItem.vue';

const menuIcon = 'M4 7h16M4 12h16M4 17h16';
const inspectIcon = 'M12 5v14M5 12h14';
const closeIcon = 'M6 6l12 12M18 6 6 18';

const bootstrap = ref(null);
const draft = ref(null);
const treeItems = ref([]);
const validation = ref({ issues: [], blockerCount: 0, warningCount: 0, status: 'ready', checks: [] });
const preview = ref({ items: [], meta: { visibleCount: 0, hiddenCount: 0 } });
const selectedItemId = ref('ops-console');
const editorDraft = ref(null);
const previewAudience = ref('member');
const previewDevice = ref('desktop');
const loading = ref(true);
const previewLoading = ref(false);
const saving = ref(false);
const validating = ref(false);
const publishing = ref(false);
const reordering = ref(false);
const message = ref('');
const errorMessage = ref('');
const navigatorOpen = ref(false);
const inspectorOpen = ref(false);
const previewMobileNavOpen = ref(false);
const addDialogOpen = ref(false);
const archiveDialogOpen = ref(false);
const publishDialogOpen = ref(false);
const publishReceipt = ref(null);
const releaseNote = ref('Fix operations destination and refresh audience navigation.');
const archiveReason = ref('This destination is no longer part of the primary application journey.');
const archiveTargetLabel = ref('');
const addDraft = ref(createNewItemDraft());

const selectedItem = computed(getSelectedItem);
const selectedPath = computed(getSelectedPath);
const selectedIssues = computed(getSelectedIssues);
const editorDirty = computed(getEditorDirty);
const selectedHasChildren = computed(getSelectedHasChildren);
const validationTone = computed(getValidationTone);
const validationLabel = computed(getValidationLabel);
const previewFrameClasses = computed(getPreviewFrameClasses);
const parentOptions = computed(getParentOptions);
const previewSelectedItem = computed(getPreviewSelectedItem);
const selectedVisibleInPreview = computed(getSelectedVisibleInPreview);

watch(selectedItem, synchronizeEditorDraft, { immediate: true });
watch([previewAudience, previewDevice], refreshPreviewFromControls);
onMounted(loadWorkspace);

/**
 * Return the selected item from the nested navigation tree.
 *
 * @returns {Record<string, unknown>|null} Selected navigation item.
 */
function getSelectedItem() {
	return findItem(treeItems.value, selectedItemId.value);
}

/**
 * Return the selected item breadcrumb path.
 *
 * @returns {string} Human-readable navigation path.
 */
function getSelectedPath() {
	return itemPath(treeItems.value, selectedItemId.value).map(getItemLabel).join(' / ');
}

/**
 * Return server-owned issues associated with the selected navigation item.
 *
 * @returns {Array<Record<string, unknown>>} Selected item issues.
 */
function getSelectedIssues() {
	return (validation.value?.issues || []).filter(isSelectedIssue);
}

/**
 * Compare the editable form with the latest server-backed item state.
 *
 * @returns {boolean} True when the inspector contains unsaved edits.
 */
function getEditorDirty() {
	if (!selectedItem.value || !editorDraft.value) return false;
	return JSON.stringify(editableSnapshot(selectedItem.value)) !== JSON.stringify(editableSnapshot(editorDraft.value));
}

/**
 * Check whether the selected item owns active child destinations.
 *
 * @returns {boolean} True when the selected item has children.
 */
function getSelectedHasChildren() {
	return Boolean(selectedItem.value?.children?.length);
}

/**
 * Map the current validation report to a DOM Studio status tone.
 *
 * @returns {string} Status tone.
 */
function getValidationTone() {
	if (validation.value?.blockerCount) return 'danger';
	if (validation.value?.warningCount) return 'warning';
	return 'success';
}

/**
 * Return the concise status label shown in the editor toolbar.
 *
 * @returns {string} Validation status label.
 */
function getValidationLabel() {
	if (validation.value?.blockerCount) return `${validation.value.blockerCount} blocker${validation.value.blockerCount === 1 ? '' : 's'}`;
	if (draft.value?.status === 'published') return `Version ${draft.value.publishedVersion} live`;
	return 'Ready to publish';
}

/**
 * Size the app-shell preview to its selected target without overflowing the iframe.
 *
 * @returns {Array<string>} Preview frame classes.
 */
function getPreviewFrameClasses() {
	const base = 'relative mx-auto h-full min-h-[32rem] transition-[width,max-width] duration-200';
	if (previewDevice.value === 'mobile') return [base, 'w-full max-w-[24rem]'];
	if (previewDevice.value === 'compact') return [base, 'w-full max-w-3xl'];
	return [base, 'w-full max-w-5xl'];
}

/**
 * Build valid parent options for the add-item dialog.
 *
 * @returns {Array<Record<string, string>>} Parent select options.
 */
function getParentOptions() {
	const options = [{ value: 'root', label: 'Top level', description: 'Place beside the primary destinations.' }];
	for (const item of flattenItems(treeItems.value)) {
		if (itemPath(treeItems.value, item.id).length >= 3) continue;
		options.push({ value: item.id, label: item.label, description: item.url });
	}
	return options;
}

/**
 * Return the selected item when it survives the current server preview policy.
 *
 * @returns {Record<string, unknown>|null} Visible preview item.
 */
function getPreviewSelectedItem() {
	return findItem(preview.value.items || [], selectedItemId.value) || flattenItems(preview.value.items || [])[0] || null;
}

/**
 * Check whether the editor selection is visible to the active preview audience.
 *
 * @returns {boolean} True when the selected item is present in the preview tree.
 */
function getSelectedVisibleInPreview() {
	return Boolean(findItem(preview.value.items || [], selectedItemId.value));
}

/**
 * Synchronize the inspector with a newly selected server-backed item.
 *
 * @param {Record<string, unknown>|null} item Selected navigation item.
 * @returns {void}
 */
function synchronizeEditorDraft(item) {
	editorDraft.value = item ? editableSnapshot(item) : null;
}

/**
 * Refresh the server-filtered preview after an audience or device change.
 *
 * @returns {Promise<void>} Resolves after the preview refresh completes.
 */
async function refreshPreviewFromControls() {
	previewMobileNavOpen.value = false;
	await loadPreview();
}

/**
 * Load the complete navigation workspace and its initial policy preview.
 *
 * @returns {Promise<void>} Resolves after the editor is ready.
 */
async function loadWorkspace() {
	loading.value = true;
	errorMessage.value = '';
	try {
		applyResponsivePreviewDefault();
		const result = await apiRequest('/api/block-demos/app-navigation-builder/bootstrap');
		bootstrap.value = result;
		draft.value = result.draft;
		treeItems.value = result.items;
		validation.value = result.validation;
		if (!findItem(result.items, selectedItemId.value)) selectedItemId.value = result.items[0]?.id || '';
		await loadPreview();
	} catch (error) {
		errorMessage.value = error.message;
	} finally {
		loading.value = false;
	}
}

/**
 * Choose a useful initial app-shell target for the iframe or mobile viewport.
 *
 * @returns {void}
 */
function applyResponsivePreviewDefault() {
	if (typeof window === 'undefined') return;
	if (window.matchMedia('(max-width: 639px)').matches) {
		previewDevice.value = 'mobile';
		return;
	}
	if (window.matchMedia('(max-width: 1023px)').matches) previewDevice.value = 'compact';
}

/**
 * Load the server-owned menu visible to the selected preview persona.
 *
 * @returns {Promise<void>} Resolves after the preview is updated.
 */
async function loadPreview() {
	if (!bootstrap.value) return;
	previewLoading.value = true;
	try {
		const query = new URLSearchParams({
			audience: previewAudience.value,
			device: previewDevice.value,
		});
		preview.value = await apiRequest(`/api/block-demos/app-navigation-builder/preview?${query}`);
	} catch (error) {
		errorMessage.value = error.message;
	} finally {
		previewLoading.value = false;
	}
}

/**
 * Select one navigation item and expose its inspector on constrained screens.
 *
 * @param {string} itemId Stable navigation item identifier.
 * @returns {void}
 */
function selectItem(itemId) {
	selectedItemId.value = String(itemId);
	navigatorOpen.value = false;
	if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1279px)').matches) inspectorOpen.value = true;
}

/**
 * Save the selected item through the optimistic API contract.
 *
 * @returns {Promise<void>} Resolves after the server accepts or rejects the edit.
 */
async function saveSelectedItem() {
	if (!editorDraft.value || !draft.value) return;
	saving.value = true;
	clearFeedback();
	try {
		const result = await apiRequest(`/api/block-demos/app-navigation-builder/items/${encodeURIComponent(editorDraft.value.id)}`, {
			method: 'PATCH',
			body: { ...editableSnapshot(editorDraft.value), revision: draft.value.revision },
		});
		applyMutation(result);
		message.value = result.message;
		await loadPreview();
	} catch (error) {
		handleApiError(error);
	} finally {
		saving.value = false;
	}
}

/**
 * Persist a tree reorder produced by pointer drag or keyboard commands.
 *
 * @param {Array<Record<string, unknown>>} items Reordered nested tree.
 * @returns {Promise<void>} Resolves after the server persists the hierarchy.
 */
async function persistReorder(items) {
	if (!draft.value || reordering.value) return;
	const previousItems = treeItems.value;
	treeItems.value = items;
	reordering.value = true;
	clearFeedback();
	try {
		const result = await apiRequest('/api/block-demos/app-navigation-builder/reorder', {
			method: 'POST',
			body: { items: compactTree(items), revision: draft.value.revision },
		});
		applyMutation(result);
		message.value = result.message;
		await loadPreview();
	} catch (error) {
		treeItems.value = previousItems;
		handleApiError(error);
	} finally {
		reordering.value = false;
	}
}

/**
 * Open the add-item flow with a useful parent default.
 *
 * @returns {void}
 */
function openAddDialog() {
	addDraft.value = createNewItemDraft(selectedItem.value?.id || 'root');
	addDialogOpen.value = true;
	navigatorOpen.value = false;
}

/**
 * Add a destination to the active navigation draft.
 *
 * @returns {Promise<void>} Resolves after the server creates the item.
 */
async function createItem() {
	if (!draft.value) return;
	saving.value = true;
	clearFeedback();
	try {
		const result = await apiRequest('/api/block-demos/app-navigation-builder/items', {
			method: 'POST',
			body: { ...addDraft.value, revision: draft.value.revision },
		});
		applyMutation(result);
		selectedItemId.value = result.item.id;
		addDialogOpen.value = false;
		message.value = result.message;
		await loadPreview();
	} catch (error) {
		handleApiError(error);
	} finally {
		saving.value = false;
	}
}

/**
 * Open the archive confirmation for the selected leaf item.
 *
 * @returns {void}
 */
function openArchiveDialog() {
	archiveTargetLabel.value = selectedItem.value?.label || 'This item';
	archiveReason.value = 'This destination is no longer part of the primary application journey.';
	archiveDialogOpen.value = true;
}

/**
 * Archive the selected leaf item with an audit reason.
 *
 * @returns {Promise<void>} Resolves after the server archives the item.
 */
async function archiveSelectedItem() {
	if (!selectedItem.value || !draft.value) return;
	saving.value = true;
	clearFeedback();
	try {
		const result = await apiRequest(`/api/block-demos/app-navigation-builder/items/${encodeURIComponent(selectedItem.value.id)}/archive`, {
			method: 'POST',
			body: { revision: draft.value.revision, reason: archiveReason.value },
		});
		applyMutation(result);
		selectedItemId.value = treeItems.value[0]?.id || '';
		archiveDialogOpen.value = false;
		inspectorOpen.value = false;
		message.value = result.message;
		await loadPreview();
	} catch (error) {
		handleApiError(error);
	} finally {
		saving.value = false;
	}
}

/**
 * Run route, audience, feature-flag, and hierarchy checks on the server.
 *
 * @returns {Promise<boolean>} True when the validation request succeeds.
 */
async function runValidation() {
	if (!draft.value) return false;
	validating.value = true;
	clearFeedback();
	try {
		const result = await apiRequest('/api/block-demos/app-navigation-builder/validate', {
			method: 'POST',
			body: { revision: draft.value.revision },
		});
		validation.value = result.validation;
		draft.value = result.draft;
		message.value = result.message;
		return true;
	} catch (error) {
		handleApiError(error);
		return false;
	} finally {
		validating.value = false;
	}
}

/**
 * Refresh validation and open the publish review dialog.
 *
 * @returns {Promise<void>} Resolves after the review dialog opens.
 */
async function openPublishDialog() {
	publishReceipt.value = null;
	const loaded = await runValidation();
	if (loaded) publishDialogOpen.value = true;
}

/**
 * Publish an immutable navigation version after all server checks pass.
 *
 * @returns {Promise<void>} Resolves after publish succeeds or fails.
 */
async function publishDraft() {
	if (!draft.value) return;
	publishing.value = true;
	clearFeedback();
	try {
		const result = await apiRequest('/api/block-demos/app-navigation-builder/publish', {
			method: 'POST',
			body: { revision: draft.value.revision, releaseNote: releaseNote.value },
		});
		applyMutation(result);
		publishReceipt.value = result.receipt;
		message.value = result.message;
	} catch (error) {
		handleApiError(error);
	} finally {
		publishing.value = false;
	}
}

/**
 * Apply a successful API mutation as the new client source of truth.
 *
 * @param {Record<string, unknown>} result Mutation response.
 * @returns {void}
 */
function applyMutation(result) {
	if (result.draft) draft.value = result.draft;
	if (result.items) treeItems.value = result.items;
	if (result.validation) validation.value = result.validation;
	const nextSelected = findItem(treeItems.value, selectedItemId.value);
	if (!nextSelected) selectedItemId.value = treeItems.value[0]?.id || '';
	synchronizeEditorDraft(findItem(treeItems.value, selectedItemId.value));
}

/**
 * Keep the inspector drawer open after teleported DOM Studio controls commit.
 *
 * @returns {void}
 */
function keepInspectorDrawerOpen() {
	inspectorOpen.value = true;
	setTimeout(() => { inspectorOpen.value = true; }, 24);
}

/**
 * Toggle the mobile app-shell menu inside the live preview.
 *
 * @returns {void}
 */
function togglePreviewNavigation() {
	previewMobileNavOpen.value = !previewMobileNavOpen.value;
}

/**
 * Close the publish dialog and clear any previous receipt.
 *
 * @returns {void}
 */
function closePublishDialog() {
	publishDialogOpen.value = false;
	publishReceipt.value = null;
}

/**
 * Clear transient success and error feedback before a new request.
 *
 * @returns {void}
 */
function clearFeedback() {
	message.value = '';
	errorMessage.value = '';
}

/**
 * Surface an API error and synchronize conflict state when available.
 *
 * @param {Error & { payload?: Record<string, unknown> }} error Request error.
 * @returns {void}
 */
function handleApiError(error) {
	errorMessage.value = error.message;
	if (error.payload?.draft) draft.value = error.payload.draft;
	if (error.payload?.items) treeItems.value = error.payload.items;
}

/**
 * Request one demo API route and convert non-success responses into rich errors.
 *
 * @param {string} path API route.
 * @param {{ method?: string, body?: Record<string, unknown> }} [options={}] Request options.
 * @returns {Promise<Record<string, unknown>>} Parsed API payload.
 */
async function apiRequest(path, options = {}) {
	const response = await fetch(path, {
		method: options.method || 'GET',
		headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
		body: options.body ? JSON.stringify(options.body) : undefined,
	});
	const payload = await response.json();
	if (!response.ok) {
		const error = new Error(payload.message || 'The navigation request could not be completed.');
		error.payload = payload;
		throw error;
	}
	return payload;
}

/**
 * Return the first item matching an identifier in a nested tree.
 *
 * @param {Array<Record<string, unknown>>} items Nested items.
 * @param {string} itemId Stable identifier.
 * @returns {Record<string, unknown>|null} Matching item or null.
 */
function findItem(items, itemId) {
	for (const item of items || []) {
		if (String(item.id) === String(itemId)) return item;
		const child = findItem(item.children || [], itemId);
		if (child) return child;
	}
	return null;
}

/**
 * Return the ancestor path for one nested item.
 *
 * @param {Array<Record<string, unknown>>} items Nested items.
 * @param {string} itemId Stable identifier.
 * @param {Array<Record<string, unknown>>} [parents=[]] Parent accumulator.
 * @returns {Array<Record<string, unknown>>} Ancestor and selected items.
 */
function itemPath(items, itemId, parents = []) {
	for (const item of items || []) {
		const nextParents = [...parents, item];
		if (String(item.id) === String(itemId)) return nextParents;
		const childPath = itemPath(item.children || [], itemId, nextParents);
		if (childPath.length) return childPath;
	}
	return [];
}

/**
 * Flatten a nested tree while preserving its visible order.
 *
 * @param {Array<Record<string, unknown>>} items Nested items.
 * @returns {Array<Record<string, unknown>>} Flat item list.
 */
function flattenItems(items) {
	const rows = [];
	for (const item of items || []) rows.push(item, ...flattenItems(item.children || []));
	return rows;
}

/**
 * Return a serializable tree containing only identifiers and children.
 *
 * @param {Array<Record<string, unknown>>} items Nested tree items.
 * @returns {Array<Record<string, unknown>>} Compact reorder payload.
 */
function compactTree(items) {
	return (items || []).map(compactTreeItem);
}

/**
 * Serialize one item for the reorder API.
 *
 * @param {Record<string, unknown>} item Tree item.
 * @returns {Record<string, unknown>} Compact tree item.
 */
function compactTreeItem(item) {
	return { id: item.id, children: compactTree(item.children || []) };
}

/**
 * Return only fields editable through the item inspector.
 *
 * @param {Record<string, unknown>} item Navigation item.
 * @returns {Record<string, unknown>} Editable item snapshot.
 */
function editableSnapshot(item) {
	return {
		id: item.id,
		label: item.label || '',
		url: item.url || '',
		description: item.description || '',
		icon: item.icon || '',
		visibility: item.visibility || 'everyone',
		audience: [...(item.audience || [])],
		featureFlag: item.featureFlag || '',
		requiresAuth: item.requiresAuth !== false,
		openInNewTab: item.openInNewTab === true,
	};
}

/**
 * Create a fresh add-item form model.
 *
 * @param {string} [parentId='root'] Default parent identifier.
 * @returns {Record<string, unknown>} Add-item draft.
 */
function createNewItemDraft(parentId = 'root') {
	return {
		parentId,
		label: 'Help center',
		url: '/app/help',
		description: 'Guides, product updates, and contact options.',
		visibility: 'everyone',
		audience: ['all-users'],
		featureFlag: '',
		requiresAuth: true,
		openInNewTab: false,
		icon: 'M5 5h14v14H5V5Zm3 4h8M8 12h8M8 15h5',
	};
}

/**
 * Return a navigation item label for path rendering.
 *
 * @param {Record<string, unknown>} item Navigation item.
 * @returns {string} Item label.
 */
function getItemLabel(item) {
	return String(item.label || 'Untitled');
}

/**
 * Test whether one issue belongs to the selected navigation item.
 *
 * @param {Record<string, unknown>} issue Validation issue.
 * @returns {boolean} True when the issue belongs to the selected item.
 */
function isSelectedIssue(issue) {
	return String(issue.itemId) === String(selectedItemId.value);
}

/**
 * Format a timestamp for compact editor chrome.
 *
 * @param {string|null|undefined} value ISO timestamp.
 * @returns {string} Localized timestamp label.
 */
function formatTimestamp(value) {
	if (!value) return 'Not yet';
	return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
</script>

<template>
	<div class="flex h-dvh min-h-[38rem] w-full flex-col overflow-hidden border border-border bg-canvas text-canvas-fg">
		<header class="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-canvas px-3 sm:px-4">
			<DomIconButton
				class="md:hidden"
				label="Open navigation hierarchy"
				size="sm"
				:icon="menuIcon"
				@click="navigatorOpen = true"
			/>
			<div class="grid size-8 shrink-0 place-items-center rounded-lg bg-primary text-sm font-bold text-primary-fg">N</div>
			<div class="min-w-0 flex-1">
				<p class="truncate text-sm font-semibold tracking-tight">Navigation studio</p>
				<p class="hidden truncate text-[11px] text-muted-fg sm:block">Northstar · Primary app · Production</p>
			</div>
			<div class="hidden sm:block">
				<DomStatusPill :tone="validationTone" :label="validationLabel" size="sm" />
			</div>
			<div class="hidden md:block">
				<DomButton variant="secondary" size="sm" :loading="validating" @click="runValidation">Run checks</DomButton>
			</div>
			<DomButton size="sm" @click="openPublishDialog">Publish</DomButton>
			<div class="hidden lg:block">
				<DomProfileMenu
					name="Maya Chen"
					email="maya@northstar.tools"
					initials="MC"
					:show-email="false"
					:theme-control="false"
				/>
			</div>
		</header>

		<div v-if="errorMessage || message" class="shrink-0 border-b border-border px-3 py-2 sm:px-4">
			<DomAlert
				:tone="errorMessage ? 'danger' : 'success'"
				variant="toast"
				:title="errorMessage ? 'Navigation update failed' : 'Navigation updated'"
				:description="errorMessage || message"
				dismissible
				@dismiss="clearFeedback"
			/>
		</div>

		<div v-if="loading" class="grid min-h-0 flex-1 gap-3 p-4 md:grid-cols-[17rem_minmax(0,1fr)] xl:grid-cols-[17rem_minmax(0,1fr)_21rem]">
			<DomSkeleton class="h-full min-h-80" />
			<DomSkeleton class="h-full min-h-80" />
			<DomSkeleton class="hidden h-full min-h-80 xl:block" />
		</div>

		<main
			v-else
			class="grid min-h-0 flex-1 md:grid-cols-[17rem_minmax(0,1fr)] xl:grid-cols-[17rem_minmax(0,1fr)_21rem]"
		>
			<aside class="hidden min-h-0 border-r border-border md:block">
				<NavigationTreePanel
					:items="treeItems"
					:selected-id="selectedItemId"
					:validation="validation"
					:reordering="reordering"
					@add="openAddDialog"
					@select="selectItem"
					@reorder="persistReorder"
				/>
			</aside>

			<section class="flex min-h-0 min-w-0 flex-col bg-secondary/35">
				<div class="flex min-h-14 shrink-0 items-center gap-2 overflow-x-auto border-b border-border bg-canvas px-3 sm:px-4">
					<span class="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg lg:inline">Preview as</span>
					<DomSelect
						v-model="previewAudience"
						class="min-w-0 flex-1 sm:flex-none"
						aria-label="Preview audience"
						:options="bootstrap.previewAudienceOptions"
						width="min-w-[12rem]"
					/>
					<DomSelect
						v-model="previewDevice"
						class="min-w-0 flex-1 sm:flex-none"
						aria-label="Preview device"
						:options="bootstrap.deviceOptions"
						width="min-w-[11rem]"
					/>
					<div class="hidden min-w-2 flex-1 sm:block"></div>
					<span class="hidden shrink-0 text-xs text-muted-fg sm:inline">
						{{ preview.meta.visibleCount }} visible · {{ preview.meta.hiddenCount }} filtered
					</span>
					<div class="hidden sm:block xl:hidden">
						<DomIconButton
							label="Edit selected destination"
							size="sm"
							:icon="inspectIcon"
							@click="inspectorOpen = true"
						/>
					</div>
				</div>

				<div class="min-h-0 flex-1 overflow-auto p-3 sm:p-5">
					<div :class="previewFrameClasses">
						<DomAppShell variant="card" class="border border-border shadow-xl shadow-black/10">
							<template #top>
								<DomAppTopBar
									title="Northstar"
									:subtitle="`${previewAudience.replace('-', ' ')} preview · ${bootstrap.workspace.domain}`"
								>
									<template v-if="previewDevice === 'mobile'" #leading>
										<DomIconButton label="Open preview navigation" size="sm" :icon="menuIcon" @click="togglePreviewNavigation" />
									</template>
									<template #trailing>
										<DomAvatar name="Ari Gupta" initials="AG" size="sm" />
									</template>
								</DomAppTopBar>
							</template>

							<div class="flex h-full min-h-0">
								<aside
									v-if="previewDevice !== 'mobile'"
									class="min-h-0 shrink-0 overflow-y-auto border-r border-border bg-canvas p-2"
									:class="previewDevice === 'compact' ? 'w-44' : 'w-56'"
								>
									<p class="px-2 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-fg">Workspace</p>
									<nav class="grid gap-0.5" aria-label="Server-filtered application preview">
										<PreviewMenuItem
											v-for="item in preview.items"
											:key="item.id"
											:item="item"
											:selected-id="selectedItemId"
											:compact="previewDevice === 'compact'"
											@select="selectItem"
										/>
									</nav>
								</aside>

								<div class="min-w-0 flex-1 overflow-y-auto bg-canvas">
									<div class="mx-auto max-w-4xl px-4 py-5 sm:px-6 sm:py-7">
										<DomAlert
											v-if="!selectedVisibleInPreview"
											class="mb-5"
											tone="info"
											variant="soft"
											title="Audience preview applied"
											:description="`${selectedItem?.label || 'This destination'} is hidden for this audience. Showing the first available destination instead.`"
										/>
										<div class="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
											<div>
												<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">{{ previewSelectedItem?.url || '/app' }}</p>
												<h1 class="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{{ previewSelectedItem?.label || 'Workspace overview' }}</h1>
												<p class="mt-2 max-w-xl text-sm leading-6 text-muted-fg">{{ previewSelectedItem?.description || 'Your workspace activity and next actions.' }}</p>
											</div>
											<DomButton size="sm">Create project</DomButton>
										</div>

										<div class="grid gap-6 py-6">
											<section>
												<div class="flex items-center justify-between gap-3">
													<h2 class="text-base font-semibold">Current work</h2>
													<DomButton variant="ghost" size="xs">View all</DomButton>
												</div>
												<div class="mt-3 divide-y divide-border border-y border-border">
													<div class="flex items-center gap-3 py-3">
														<DomAvatar name="Atlas rollout" initials="AR" size="sm" />
														<div class="min-w-0 flex-1">
															<p class="truncate text-sm font-medium">Atlas rollout</p>
															<p class="text-xs text-muted-fg">Design review · Today</p>
														</div>
														<DomStatusPill tone="success" label="On track" size="sm" />
													</div>
													<div class="flex items-center gap-3 py-3">
														<DomAvatar name="Beacon migration" initials="BM" size="sm" />
														<div class="min-w-0 flex-1">
															<p class="truncate text-sm font-medium">Beacon migration</p>
															<p class="text-xs text-muted-fg">Engineering · Tomorrow</p>
														</div>
														<DomStatusPill tone="warning" label="At risk" size="sm" />
													</div>
													<div class="flex items-center gap-3 py-3">
														<DomAvatar name="Comet launch" initials="CL" size="sm" />
														<div class="min-w-0 flex-1">
															<p class="truncate text-sm font-medium">Comet launch</p>
															<p class="text-xs text-muted-fg">Content · Friday</p>
														</div>
														<DomStatusPill tone="primary" label="Review" size="sm" />
													</div>
												</div>
											</section>

											<aside class="border-t border-border pt-5">
												<div class="flex items-end justify-between gap-4">
													<div>
														<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">This week</p>
														<p class="mt-2 text-3xl font-semibold tracking-tight">82%</p>
													</div>
													<p class="max-w-44 text-right text-xs leading-5 text-muted-fg">Milestones completed on time</p>
												</div>
												<div class="mt-4 grid grid-cols-3 divide-x divide-border border-y border-border py-3 text-center text-xs">
													<div><strong class="block text-base">12</strong><span class="text-muted-fg">Projects</span></div>
													<div><strong class="block text-base">4</strong><span class="text-muted-fg">Reviews</span></div>
													<div><strong class="block text-base">18</strong><span class="text-muted-fg">Online</span></div>
												</div>
											</aside>
										</div>
									</div>
								</div>
							</div>

							<template #overlay>
								<div v-if="previewDevice === 'mobile' && previewMobileNavOpen" class="pointer-events-auto absolute inset-0 flex bg-black/35">
									<aside class="flex h-full w-[82%] max-w-72 flex-col border-r border-border bg-canvas shadow-xl">
										<div class="flex items-center justify-between border-b border-border px-3 py-3">
											<div>
												<p class="text-sm font-semibold">Northstar</p>
												<p class="text-xs text-muted-fg">Workspace navigation</p>
											</div>
											<DomIconButton label="Close preview navigation" size="sm" :icon="closeIcon" @click="togglePreviewNavigation" />
										</div>
										<nav class="min-h-0 flex-1 overflow-y-auto p-2" aria-label="Mobile application preview">
											<PreviewMenuItem
												v-for="item in preview.items"
												:key="item.id"
												:item="item"
												:selected-id="selectedItemId"
												@select="selectItem"
											/>
										</nav>
									</aside>
									<button type="button" class="min-w-0 flex-1" aria-label="Close preview navigation" @click="togglePreviewNavigation"></button>
								</div>
							</template>
						</DomAppShell>
					</div>
				</div>

				<footer class="flex min-h-13 shrink-0 items-center justify-between gap-3 border-t border-border bg-canvas px-3 xl:hidden">
					<div class="min-w-0">
						<p class="truncate text-sm font-medium">{{ selectedItem?.label }}</p>
						<p class="truncate text-xs text-muted-fg">{{ selectedIssues.length ? `${selectedIssues.length} issue${selectedIssues.length === 1 ? '' : 's'}` : 'Ready to publish' }}</p>
					</div>
					<DomButton variant="secondary" size="sm" @click="inspectorOpen = true">Edit destination</DomButton>
				</footer>
			</section>

			<aside class="hidden min-h-0 border-l border-border xl:block">
				<NavigationInspector
					v-if="editorDraft"
					v-model="editorDraft"
					:visibility-options="bootstrap.visibilityOptions"
					:audience-options="bootstrap.audienceOptions"
					:icon-options="bootstrap.iconOptions"
					:issues="selectedIssues"
					:path="selectedPath"
					:saving="saving"
					:dirty="editorDirty"
					:has-children="selectedHasChildren"
					@save="saveSelectedItem"
					@archive="openArchiveDialog"
				/>
			</aside>
		</main>

		<DomDrawer v-model="navigatorOpen" side="left" width="min(22rem, 92vw)" title="Navigation hierarchy" :static="true">
			<NavigationTreePanel
				:items="treeItems"
				:selected-id="selectedItemId"
				:validation="validation"
				:reordering="reordering"
				@add="openAddDialog"
				@select="selectItem"
				@reorder="persistReorder"
			/>
		</DomDrawer>

		<DomDrawer v-model="inspectorOpen" side="right" width="min(25rem, 94vw)" title="Destination inspector" :static="true">
			<NavigationInspector
				v-if="editorDraft"
				v-model="editorDraft"
				:visibility-options="bootstrap?.visibilityOptions || []"
				:audience-options="bootstrap?.audienceOptions || []"
				:icon-options="bootstrap?.iconOptions || []"
				:issues="selectedIssues"
				:path="selectedPath"
				:saving="saving"
				:dirty="editorDirty"
				:has-children="selectedHasChildren"
				@save="saveSelectedItem"
				@archive="openArchiveDialog"
				@floating-interaction="keepInspectorDrawerOpen"
			/>
		</DomDrawer>

		<DomDialog
			v-model="addDialogOpen"
			title="Add navigation destination"
			description="Create a real draft item, then validate it against the application router."
		>
			<div class="grid gap-4 sm:grid-cols-2">
				<DomTextInput v-model="addDraft.label" label="Menu label" placeholder="Help center" />
				<DomTextInput v-model="addDraft.url" label="Destination" placeholder="/app/help" />
				<DomSelect
					v-model="addDraft.parentId"
					label="Parent"
					:options="parentOptions"
					width="min-w-[18rem]"
				/>
				<DomSelect
					v-model="addDraft.visibility"
					label="Visibility"
					:options="bootstrap?.visibilityOptions || []"
					width="min-w-[18rem]"
				/>
				<div class="sm:col-span-2">
					<DomTextareaInput v-model="addDraft.description" label="Internal description" :rows="3" />
				</div>
				<div class="sm:col-span-2">
					<DomIconSelector
						v-model="addDraft.icon"
						label="Navigation icon"
						kind="custom"
						:items="bootstrap?.iconOptions || []"
						:include-built-ins="false"
						:clearable="false"
					/>
				</div>
				<div v-if="addDraft.visibility === 'roles' || addDraft.visibility === 'admins'" class="sm:col-span-2">
					<DomTagCombobox
						v-model="addDraft.audience"
						label="Allowed audiences"
						:options="bootstrap?.audienceOptions || []"
					/>
				</div>
				<DomToggle v-model="addDraft.requiresAuth" label="Requires sign in" />
				<DomToggle v-model="addDraft.openInNewTab" label="Open in a new tab" />
			</div>
			<template #footer>
				<DomButton variant="secondary" size="sm" @click="addDialogOpen = false">Cancel</DomButton>
				<DomButton size="sm" :loading="saving" @click="createItem">Add destination</DomButton>
			</template>
		</DomDialog>

		<DomDialog
			v-model="archiveDialogOpen"
			title="Archive navigation item?"
			:description="`${archiveTargetLabel} will leave the draft tree and every audience preview.`"
		>
			<DomTextareaInput v-model="archiveReason" label="Archive reason" :rows="3" />
			<template #footer>
				<DomButton variant="secondary" size="sm" @click="archiveDialogOpen = false">Keep item</DomButton>
				<DomButton variant="danger" size="sm" :loading="saving" @click="archiveSelectedItem">Archive item</DomButton>
			</template>
		</DomDialog>

		<DomDialog
			v-model="publishDialogOpen"
			:title="publishReceipt ? `Version ${publishReceipt.version} is live` : 'Review navigation publish'"
			:description="publishReceipt ? 'The API returned an immutable deployment receipt.' : 'Publish the validated menu used by the production app shell.'"
		>
			<div v-if="publishReceipt" class="space-y-4">
				<DomAlert tone="success" title="Navigation published" :description="`${publishReceipt.itemCount} destinations are live in production.`" />
				<dl class="grid grid-cols-2 gap-x-4 gap-y-3 rounded-lg border border-border bg-secondary/35 p-4 text-sm">
					<div><dt class="text-xs text-muted-fg">Receipt</dt><dd class="mt-1 font-mono">{{ publishReceipt.id }}</dd></div>
					<div><dt class="text-xs text-muted-fg">Checksum</dt><dd class="mt-1 font-mono">{{ publishReceipt.checksum }}</dd></div>
					<div><dt class="text-xs text-muted-fg">Published</dt><dd class="mt-1">{{ formatTimestamp(publishReceipt.publishedAt) }}</dd></div>
					<div><dt class="text-xs text-muted-fg">Editor</dt><dd class="mt-1">{{ publishReceipt.publishedBy }}</dd></div>
				</dl>
			</div>
			<div v-else class="space-y-4">
				<DomAlert
					:tone="validation.blockerCount ? 'danger' : 'success'"
					:title="validation.blockerCount ? `${validation.blockerCount} blocker${validation.blockerCount === 1 ? '' : 's'} must be resolved` : 'All required checks passed'"
					:description="validation.blockerCount ? 'Select a marked item in the hierarchy, fix its destination, and run checks again.' : `Version ${draft?.publishedVersion + 1} can be published to ${bootstrap?.workspace.environment}.`"
				/>
				<div class="grid gap-2">
					<div v-for="check in validation.checks" :key="check.key" class="flex items-center justify-between border-b border-border py-2 text-sm">
						<span>{{ check.label }}</span>
						<DomStatusPill :tone="check.healthy ? 'success' : 'danger'" :label="check.healthy ? 'Passed' : 'Blocked'" size="sm" />
					</div>
				</div>
				<DomTextareaInput v-model="releaseNote" label="Release note" :rows="3" />
			</div>
			<template #footer>
				<DomButton variant="secondary" size="sm" @click="closePublishDialog">{{ publishReceipt ? 'Done' : 'Keep editing' }}</DomButton>
				<DomButton
					v-if="!publishReceipt"
					size="sm"
					:loading="publishing"
					:disabled="validation.blockerCount > 0"
					@click="publishDraft"
				>
					Publish version {{ draft?.publishedVersion + 1 }}
				</DomButton>
			</template>
		</DomDialog>
	</div>
</template>

Local components

Copy the editor helpers

vue
<script setup>
import { computed } from 'vue';
import { DomIconButton, DomStatusPill, DomTreeView } from '@getdom/studio/vue';

const props = defineProps({
	items: {
		type: Array,
		default: () => [],
	},
	selectedId: {
		type: String,
		default: '',
	},
	validation: {
		type: Object,
		default: () => ({ issues: [] }),
	},
	reordering: {
		type: Boolean,
		default: false,
	},
});

const emit = defineEmits(['add', 'select', 'reorder']);

const blockerIds = computed(getBlockerIds);

/**
 * Return the navigation item identifiers with blocking validation issues.
 *
 * @returns {Set<string>} Item identifiers with blockers.
 */
function getBlockerIds() {
	return new Set((props.validation?.issues || [])
		.filter((issue) => issue.severity === 'blocker')
		.map((issue) => issue.itemId));
}

/**
 * Map a navigation visibility rule to a compact human-readable label.
 *
 * @param {string} visibility Visibility rule value.
 * @returns {string} Compact visibility label.
 */
function visibilityLabel(visibility) {
	if (visibility === 'roles') return 'Gated';
	if (visibility === 'admins') return 'Admin';
	if (visibility === 'hidden') return 'Hidden';
	return '';
}

/**
 * Map a navigation visibility rule to a DOM Studio status tone.
 *
 * @param {string} visibility Visibility rule value.
 * @returns {string} Status pill tone.
 */
function visibilityTone(visibility) {
	if (visibility === 'admins') return 'danger';
	if (visibility === 'hidden') return 'warning';
	return 'primary';
}

/**
 * Forward tree selection using only the stable item identifier.
 *
 * @param {Record<string, unknown>} payload DomTreeView selection payload.
 * @returns {void}
 */
function selectTreeItem(payload) {
	emit('select', String(payload.value));
}
</script>

<template>
	<div class="flex h-full min-h-0 flex-col bg-canvas">
		<div class="shrink-0 border-b border-border px-3 py-3">
			<div class="flex items-center justify-between gap-3">
				<div class="min-w-0">
					<p class="font-semibold tracking-tight">Primary navigation</p>
					<p class="mt-0.5 text-xs text-muted-fg">Drag or use Alt + arrow keys to reorder</p>
				</div>
				<DomIconButton
					label="Add navigation item"
					size="sm"
					variant="secondary"
					icon="M12 5v14M5 12h14"
					@click="emit('add')"
				/>
			</div>
		</div>

		<div class="min-h-0 flex-1 overflow-y-auto p-2">
			<DomTreeView
				:model-value="props.selectedId"
				:items="props.items"
				label="Application navigation hierarchy"
				:open-values="['projects']"
				variant="finder"
				density="compact"
				:draggable="!props.reordering"
				@select="selectTreeItem"
				@update:items="emit('reorder', $event)"
			>
				<template #item="{ item, selected }">
					<span class="flex min-w-0 flex-1 items-center gap-2">
						<span
							v-if="blockerIds.has(item.id)"
							class="size-2 shrink-0 rounded-full bg-destructive"
							aria-label="Publishing blocker"
						></span>
						<span class="min-w-0 flex-1">
							<span class="block truncate text-sm font-medium" :class="selected ? 'text-canvas-fg' : 'text-muted-fg'">{{ item.label }}</span>
							<span class="block truncate text-[11px] text-muted-fg/75">{{ item.url }}</span>
						</span>
						<DomStatusPill
							v-if="visibilityLabel(item.visibility)"
							:tone="visibilityTone(item.visibility)"
							:label="visibilityLabel(item.visibility)"
							size="sm"
							:dot="false"
						/>
					</span>
				</template>
			</DomTreeView>
		</div>

		<div class="shrink-0 border-t border-border px-3 py-3 text-xs text-muted-fg">
			<div class="flex items-center justify-between gap-3">
				<span>{{ props.validation?.blockerCount || 0 }} blocker{{ props.validation?.blockerCount === 1 ? '' : 's' }}</span>
				<span v-if="props.reordering">Saving order…</span>
				<span v-else>{{ props.items.length }} top-level items</span>
			</div>
		</div>
	</div>
</template>
vue
<script setup>
import { computed } from 'vue';
import {
	DomAlert,
	DomButton,
	DomIconSelector,
	DomSelect,
	DomStatusPill,
	DomTagCombobox,
	DomTextInput,
	DomTextareaInput,
	DomToggle,
} from '@getdom/studio/vue';

const model = defineModel({
	type: Object,
	required: true,
});

const props = defineProps({
	visibilityOptions: {
		type: Array,
		default: () => [],
	},
	audienceOptions: {
		type: Array,
		default: () => [],
	},
	iconOptions: {
		type: Array,
		default: () => [],
	},
	issues: {
		type: Array,
		default: () => [],
	},
	path: {
		type: String,
		default: '',
	},
	saving: {
		type: Boolean,
		default: false,
	},
	dirty: {
		type: Boolean,
		default: false,
	},
	hasChildren: {
		type: Boolean,
		default: false,
	},
});

const emit = defineEmits(['save', 'archive', 'floating-interaction']);

const primaryIssue = computed(getPrimaryIssue);
const issueTone = computed(getIssueTone);

/**
 * Return the first server-owned issue associated with the selected item.
 *
 * @returns {Record<string, unknown>|null} Highest-priority validation issue.
 */
function getPrimaryIssue() {
	return props.issues.find((issue) => issue.severity === 'blocker') || props.issues[0] || null;
}

/**
 * Map the selected issue severity to a DOM Studio alert tone.
 *
 * @returns {string} Alert tone.
 */
function getIssueTone() {
	return primaryIssue.value?.severity === 'blocker' ? 'danger' : 'warning';
}

/**
 * Keep a containing drawer open after a teleported picker commits a value.
 *
 * @returns {void}
 */
function notifyFloatingInteraction() {
	setTimeout(() => emit('floating-interaction'), 0);
}
</script>

<template>
	<div class="flex h-full min-h-0 flex-col bg-canvas">
		<div class="shrink-0 border-b border-border px-4 py-4">
			<div class="flex items-start justify-between gap-3">
				<div class="min-w-0">
					<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg">Selected destination</p>
					<h2 class="mt-1 truncate text-lg font-semibold tracking-tight">{{ model.label }}</h2>
					<p class="mt-1 truncate text-xs text-muted-fg">{{ props.path }}</p>
				</div>
				<DomStatusPill
					:tone="primaryIssue ? issueTone : 'success'"
					:label="primaryIssue ? (primaryIssue.severity === 'blocker' ? 'Needs attention' : 'Review') : 'Ready'"
					size="sm"
				/>
			</div>
		</div>

		<div class="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-5">
			<DomAlert
				v-if="primaryIssue"
				:tone="issueTone"
				variant="soft"
				:title="primaryIssue.severity === 'blocker' ? 'Publishing blocker' : 'Check recommended'"
				:description="primaryIssue.message"
			/>

			<section class="space-y-4">
				<div>
					<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Content</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">The label and destination people see in the app shell.</p>
				</div>
				<DomTextInput v-model="model.label" label="Menu label" placeholder="Operations" />
				<DomTextInput
					v-model="model.url"
					label="Destination"
					placeholder="/app/ops"
					:invalid="primaryIssue?.field === 'url'"
					:error="primaryIssue?.field === 'url' ? primaryIssue.message : ''"
				/>
				<DomTextareaInput
					v-model="model.description"
					label="Internal description"
					placeholder="Explain where this item sends people"
					:rows="3"
				/>
				<DomIconSelector
					v-model="model.icon"
					label="Navigation icon"
					kind="custom"
					:items="props.iconOptions"
					:include-built-ins="false"
					:clearable="false"
					placeholder="Search navigation icons"
					@select="notifyFloatingInteraction"
				/>
			</section>

			<section class="space-y-4 border-t border-border pt-5">
				<div>
					<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Access</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">Preview visibility here; route authorization remains server-owned.</p>
				</div>
				<DomSelect
					v-model="model.visibility"
					label="Visibility"
					:options="props.visibilityOptions"
					width="min-w-[18rem]"
					@select="notifyFloatingInteraction"
				>
					<template #option="{ option }">
						<span class="block">
							<span class="block text-sm font-semibold">{{ option.label }}</span>
							<span class="mt-0.5 block text-xs leading-5 opacity-75">{{ option.description }}</span>
						</span>
					</template>
				</DomSelect>
				<DomTagCombobox
					v-if="model.visibility === 'roles' || model.visibility === 'admins'"
					v-model="model.audience"
					label="Allowed audiences"
					:options="props.audienceOptions"
					placeholder="Add roles or plan cohorts"
					clearable
					@select="notifyFloatingInteraction"
				/>
				<DomTextInput v-model="model.featureFlag" label="Feature flag" placeholder="Optional flag key" />
				<div class="grid gap-3 rounded-lg border border-border bg-secondary/35 p-3">
					<DomToggle v-model="model.requiresAuth" label="Requires sign in" />
					<DomToggle v-model="model.openInNewTab" label="Open in a new tab" />
				</div>
			</section>
		</div>

		<div class="shrink-0 border-t border-border bg-canvas px-4 py-3">
			<div class="flex items-center justify-between gap-3">
				<DomButton
					variant="ghost"
					size="sm"
					:disabled="props.hasChildren || props.saving"
					@click="emit('archive')"
				>
					Archive
				</DomButton>
				<DomButton
					size="sm"
					:loading="props.saving"
					:disabled="!props.dirty"
					@click="emit('save')"
				>
					Save changes
				</DomButton>
			</div>
			<p v-if="props.hasChildren" class="mt-2 text-xs leading-5 text-muted-fg">Move or archive child items before archiving this parent.</p>
		</div>
	</div>
</template>
vue
<script setup>
import { DomBadge, DomIconButton } from '@getdom/studio/vue';

defineOptions({
	name: 'PreviewMenuItem',
});

const props = defineProps({
	item: {
		type: Object,
		required: true,
	},
	selectedId: {
		type: String,
		default: '',
	},
	depth: {
		type: Number,
		default: 0,
	},
	compact: {
		type: Boolean,
		default: false,
	},
});

defineEmits(['select']);

/**
 * Return the short audience marker shown beside gated preview rows.
 *
 * @param {Record<string, unknown>} item Navigation preview item.
 * @returns {string} Short policy label.
 */
function audienceLabel(item) {
	if (item.visibility === 'admins') return 'Admin';
	if (item.visibility === 'roles') return 'Gated';
	return '';
}
</script>

<template>
	<div class="grid gap-0.5">
		<button
			type="button"
			class="group flex min-h-10 w-full items-center gap-2 rounded-md pr-2 text-left text-sm outline-none transition hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring/50"
			:class="selectedId === item.id ? 'bg-primary/10 text-canvas-fg' : 'text-muted-fg'"
			:style="{ paddingLeft: `${0.5 + depth * 0.75}rem` }"
			@click="$emit('select', item.id)"
		>
			<DomIconButton
				as="span"
				class="pointer-events-none"
				size="xs"
				:active="selectedId === item.id"
				:icon="item.icon"
				:label="`${item.label} icon`"
			/>
			<span class="min-w-0 flex-1 truncate font-medium text-canvas-fg">{{ item.label }}</span>
			<DomBadge
				v-if="audienceLabel(item) && !compact"
				tone="neutral"
				variant="outline"
				size="sm"
			>
				{{ audienceLabel(item) }}
			</DomBadge>
		</button>

		<div v-if="item.children?.length" class="grid gap-0.5">
			<PreviewMenuItem
				v-for="child in item.children"
				:key="child.id"
				:item="child"
				:selected-id="props.selectedId"
				:depth="props.depth + 1"
				:compact="props.compact"
				@select="$emit('select', $event)"
			/>
		</div>
	</div>
</template>

Integration

How the working section fits together

Use this block when builders need to manage application navigation as versioned product data instead of a hard-coded route file. It combines a draggable hierarchy, focused metadata editing, server-filtered audience previews, route health checks, and deployment proof.

  • GET /api/block-demos/app-navigation-builder/bootstrap returns the draft, hierarchy, editor options, and server validation report.
  • Item create, patch, archive, and reorder routes require the current draft revision and return 409 for stale writes.
  • The preview route resolves representative audience entitlements on the server before returning visible menu items.
  • The validation route checks registered routes, feature flags, audience rules, and hierarchy depth before publishing.
  • Publishing creates a new immutable version with a timestamped checksum receipt suitable for audit history.

Data

API-backed publish receipt

js
{
	id: 'nav-publish-801',
	version: 19,
	releaseNote: 'Fix operations destination and refresh audience navigation.',
	publishedBy: 'Maya Chen',
	itemCount: 9,
	checksum: '3d9002616b46f1db'
}

Customization

Implementation notes

Tree persistence

The demo persists the complete nested identifier tree, validates uniqueness and depth, then recomputes parent IDs and sibling sort orders server-side.

Audience rules

Audience previews make visibility understandable, while the example keeps route authorization explicitly separate from hiding navigation links.

Production boundary

The included API is process-local. Production should add authentication, authorization, durable versions, route discovery, deploy orchestration, and immutable audit storage.