primitive-app
v1.0.5
Published
Primitive App is a **Vue 3–first helper library** that wires a Vue + Pinia + vue-router application into **js-bao-wss-client** with a cohesive set of:
Readme
primitive-app
Primitive App is a Vue 3–first helper library that wires a Vue + Pinia + vue-router application into js-bao-wss-client with a cohesive set of:
- Bootstrapping helpers (
createPrimitiveApp) - Routing & auth helpers (
createPrimitiveRouter,PrimitiveRouterMeta) - Stores (app config, user, navigation, breadcrumbs, document stores)
- Layouts & components (
PrimitiveAppLayout, auth layouts, navigation & document shells) - Data-loading composables (
useJsBaoDataLoader) - Debugging & test harness utilities (Document Debugger, Test Runner)
It is designed to be used in apps that already provide:
- Vue 3
- Pinia
- vue-router
- Tailwind (or compatible utility CSS)
js-bao-wss-client(andjs-baomodels in your app)
For concrete examples, see the demo app available at https://primitive-app-demo.primitive.app/
It's recommended that apps wanting to use this library start by cloning https://github.com/Primitive-Labs/primitive-app-template
Documentation
For guides and API reference docs, see Primitive Docs: https://primitive-labs.github.io/primitive-docs/
High-level architecture
At a high level, primitive-app provides:
- Bootstrapping
createPrimitiveApp– mount your Vue app, wire in Pinia and vue-router, set up js-bao, and initialize shared stores.
- Routing & auth
createPrimitiveRouter,AuthLevel,PrimitiveRouterMeta– auth-guarded routes with breadcrumb metadata.
- Stores
- Configuration:
useAppConfigStore,useNavigationStore,useBreadcrumbsStore - User:
useUserStore - Documents:
useJsBaoDocumentsStore,useSingleDocumentStore,useMultiDocumentStore
- Configuration:
- Layouts & components
PrimitiveAppLayout,PrimitiveLoginLayout,PrimitiveStaticLayout, navigation components, document management components, shared utilities.
- Data loading
useJsBaoDataLoader– standard pattern for loading data (typically js-bao models) with subscriptions and a document readiness gate.
- Debugging & test harness
PrimitiveDebuggingSuite,PrimitiveTestRunner,DocumentDebugger, and related pages for app-level integration tests and data inspection.
Bootstrapping with createPrimitiveApp
The recommended way to start a new app is to let primitive-app own the initialization order:
- Initialize js-bao and the user store.
- Initialize app config.
- Wire the document store (single-document, multi-document, or none).
- Wire breadcrumbs to the router.
- Wire navigation to the router.
- Mount your root component.
// src/main.ts
import { createPrimitiveApp } from "primitive-app";
import App from "./App.vue";
import router from "./router/routes";
import { getAppConfig, getSingleDocumentConfig } from "./config/appConfig";
import { getJsBaoConfig, getLogLevel } from "./config/envConfig";
import { getNavigationConfig } from "./config/navigationConfig";
void createPrimitiveApp({
mainComponent: App,
router,
getAppConfig,
getJsBaoConfig,
getNavigationConfig,
getSingleDocumentConfig,
loginUrl: "/login",
getLogLevel,
});PrimitiveAppBootstrapOptions
createPrimitiveApp accepts:
- mainComponent: your root Vue component (often
App.vue). - router: a
vue-routerinstance (typically created viacreatePrimitiveRouter, see below). - getAppConfig:
() => InitializeAppConfigOptions - getJsBaoConfig:
() => JsBaoClientOptions(fromjs-bao-wss-client) - getNavigationConfig?:
() => NavigationConfig - getSingleDocumentConfig?:
() => InitializeSingleDocumentOptions - loginUrl: path to your login route (e.g.
"/login"), used by auth flows. - getLogLevel?:
() => LogLevel– overrides internal logging defaults. - mountTarget?: CSS selector or DOM element (defaults to
"#app").
Note: The
documentStoreModein yourgetAppConfigdetermines which document store(s) are initialized:
DocumentStoreMode.None: No document store is initialized.DocumentStoreMode.SingleDocumentorDocumentStoreMode.SingleDocumentWithSwitching: RequiresgetSingleDocumentConfig.DocumentStoreMode.MultiDoc: InitializesjsBaoDocumentsStoreon auth; apps register collections dynamically viamultiDocumentStore.registerCollection().
Routing & auth: createPrimitiveRouter and PrimitiveRouterMeta
Primitive App supplies an opinionated vue-router factory that:
- Enforces auth based on route metadata.
- Redirects unauthenticated users to a configured login route or URL.
- Redirects non-admin users away from admin-only routes.
- Provides breadcrumb metadata for the breadcrumbs store and layouts.
Defining routes with primitiveRouterMeta
// src/router/routes.ts
import type { RouteRecordRaw } from "vue-router";
import {
createPrimitiveRouter,
PrimitiveAppLayout,
PrimitiveLoginLayout,
PrimitiveStaticLayout,
} from "primitive-app";
import LoginPage from "@/pages/LoginPage.vue";
import HomePage from "@/pages/HomePage.vue";
import TermsPage from "@/pages/TermsPage.vue";
const routes: RouteRecordRaw[] = [
{
path: "/",
component: PrimitiveAppLayout,
children: [
{
path: "",
name: "home",
component: HomePage,
meta: {
primitiveRouterMeta: {
requireAuth: "member", // "none" | "member" | "admin"
breadcrumb: { title: "Home" },
},
},
},
{
path: "routing/:slug",
name: "routing-slug",
component: () => import("@/pages/RoutingExamplePage.vue"),
meta: {
primitiveRouterMeta: {
requireAuth: "member",
breadcrumb: {
title: "Routing",
generator: (params) => String(params.slug ?? "Routing"),
},
},
},
},
],
},
{
path: "/",
component: PrimitiveLoginLayout,
children: [{ path: "login", name: "login", component: LoginPage }],
},
{
path: "/",
component: PrimitiveStaticLayout,
children: [
{
path: "terms-of-service",
name: "terms-of-service",
component: TermsPage,
},
],
},
];
const router = createPrimitiveRouter({
routes,
// or loginUrl: "https://auth.example.com/login"
loginRouteName: "login",
});
export default router;Auth behavior
- If
primitiveRouterMeta.requireAuthis:"none": route is public."member": user must be authenticated."admin": user must be authenticated anduseUserStore().isAdmin === true.
- For member/admin routes:
- Unauthenticated users are redirected to the login route or URL with a
continueURLquery parameter. - Non-admin users on admin routes are redirected to the app's
homeRouteNamefromuseAppConfigStore.
- Unauthenticated users are redirected to the login route or URL with a
Breadcrumb metadata is consumed by useBreadcrumbsStore and PrimitiveAppLayout to render breadcrumb trails and page titles.
Stores
Primitive App provides several Pinia stores organized into three categories:
- Configuration stores – Static app configuration and UI state
- User store – Authentication and user preferences
- Document stores – js-bao document management
Configuration stores
App config store: useAppConfigStore
The app config store centralizes static configuration:
| Option | Type | Description |
| --------------------- | ------------------- | ------------------------------------------------ |
| appName | string | Display name for the app and default page titles |
| appIcon | Component | Vue component rendered in login/static layouts |
| homeRouteName | RouteRecordName | Route used as the "home" destination |
| loginRouteName | RouteRecordName | Named login route |
| documentStoreMode | DocumentStoreMode | Controls document store behavior (see below) |
| pageTitleFormatter? | function | Custom page title formatter |
| loadingComponent? | Component | Global loading component used by layouts |
DocumentStoreMode options:
DocumentStoreMode.None– No document store initializedDocumentStoreMode.SingleDocument– Single document per user, no switching UIDocumentStoreMode.SingleDocumentWithSwitching– Single active document with switching/sharing UIDocumentStoreMode.MultiDoc– Multiple document collections managed dynamically
Example config:
// src/config/appConfig.ts
import appIconUrl from "@/assets/app-icon.png";
import { DocumentStoreMode, type AppIconComponent } from "primitive-app";
import { defineComponent, h } from "vue";
import type { RouteLocationNormalizedLoaded } from "vue-router";
const AppIcon: AppIconComponent = defineComponent({
name: "AppIcon",
setup() {
return () => h("img", { src: appIconUrl, alt: "App Icon" });
},
});
function defaultPageTitleFormatter(input: {
breadcrumb: string | null;
appName: string;
route: RouteLocationNormalizedLoaded;
}): string {
if (input.breadcrumb && input.breadcrumb.trim().length > 0) {
return `${input.breadcrumb} : ${input.appName}`;
}
return input.appName;
}
export function getAppConfig() {
return {
appName: "My Primitive App",
homeRouteName: "home",
loginRouteName: "login",
appIcon: AppIcon,
documentStoreMode: DocumentStoreMode.SingleDocumentWithSwitching,
pageTitleFormatter: defaultPageTitleFormatter,
} as const;
}
createPrimitiveAppcallsuseAppConfigStore().initialize(getAppConfig())during bootstrap and sets up a reactivedocument.titleusing the provided formatter.
Navigation store: useNavigationStore
The navigation store drives sidebar, bottom navigation, and user menu:
Configuration (NavigationConfig):
| Option | Type | Description |
| ------------------------------- | ------------------------------- | ----------------------------------------- |
| navOptions?.overflowMode | NavigationOverflowMode | How to handle overflow items |
| navOptions?.maxVisibleTabs | number | Maximum tabs in bottom nav (default: 5) |
| navOptions?.mobileNavEnabled | boolean | Enable mobile bottom nav (default: true) |
| navOptions?.mobileBackEnabled | boolean | Enable mobile back button (default: true) |
| navItems | Record<string, NavItemConfig> | Navigation item definitions |
NavItemConfig options:
| Option | Type | Description |
| ------------------ | -------------------------------------- | -------------------------------------------------------------- |
| key | string | Unique identifier for the item |
| navTitle | string | Display title |
| navGroup | "main" \| "secondary" \| "user-menu" | Navigation group |
| routeName? | string | Vue router route name |
| routeParams? | object | Route parameters |
| externalHref? | string | External URL |
| action? | string | Action identifier for non-navigation items (e.g., open dialog) |
| actionComponent? | Component | Vue component to render when action is triggered |
| target? | string | Link target (e.g., "_blank") |
| icon? | Component | Icon component |
| parentKey? | string | Parent item key (for nesting) |
| navHeader? | string | Header text for grouped items |
| matchRouteNames? | string[] | Additional routes that activate this item |
| mobilePriority? | number | Priority for bottom nav (lower = higher priority) |
| hidden? | boolean | Hide this item |
Note: Each nav item should provide one of
routeName,externalHref, oraction. Whenactionis provided withactionComponent, clicking the item opens the component as a dialog/sheet automatically.
Example:
// src/config/navigationConfig.ts
import { NavigationOverflowMode, type NavigationConfig } from "primitive-app";
import { BookOpen, Settings, LogOut } from "lucide-vue-next";
export function getNavigationConfig(): NavigationConfig {
return {
navOptions: {
overflowMode: NavigationOverflowMode.Always,
maxVisibleTabs: 3,
mobileNavEnabled: true,
mobileBackEnabled: true,
},
navItems: {
gettingStarted: {
key: "gettingStarted",
navTitle: "Getting Started",
navGroup: "main",
routeName: "getting-started",
icon: BookOpen,
mobilePriority: 1,
},
settings: {
key: "settings",
navTitle: "Settings",
navGroup: "user-menu",
routeName: "settings",
icon: Settings,
},
logout: {
key: "logout",
navTitle: "Log out",
navGroup: "user-menu",
routeName: "logout",
icon: LogOut,
mobilePriority: 2,
},
},
} as const;
}Action-triggered dialogs:
Navigation items can trigger dialogs or other UI instead of navigating. Use action with actionComponent to define menu items that open dialogs:
import { NavigationOverflowMode, PasskeyManagement, type NavigationConfig } from "primitive-app";
import { Key, LogOut } from "lucide-vue-next";
export function getNavigationConfig(): NavigationConfig {
return {
navItems: {
// Standard navigation item
home: {
key: "home",
navTitle: "Home",
navGroup: "main",
routeName: "home",
},
// Action item with automatic dialog rendering
managePasskeys: {
key: "managePasskeys",
navTitle: "Manage Passkeys",
navGroup: "user-menu",
action: "manage-passkeys",
actionComponent: PasskeyManagement, // Opens as dialog/sheet automatically
icon: Key,
},
logout: {
key: "logout",
navTitle: "Log out",
navGroup: "user-menu",
routeName: "logout",
icon: LogOut,
},
},
} as const;
}When an item has both action and actionComponent:
- Clicking the item opens the specified component as a dialog (desktop) or bottom sheet (mobile)
- The component receives
open(boolean prop) and should emitupdate:opento close - No custom layout wrapper is needed –
PrimitiveAppLayouthandles rendering automatically
For custom action handling without automatic dialogs, use action without actionComponent and listen for the @menu-action event on PrimitiveAppLayout:
<PrimitiveAppLayout @menu-action="handleMenuAction">
<router-view />
</PrimitiveAppLayout>Dynamic navigation actions:
const navigation = useNavigationStore();
// Add items dynamically
navigation.addNavItems({ ... });
// Remove items
navigation.removeNavItems(['itemKey']);
// Show/hide items
navigation.hideNavItems(['itemKey']);
navigation.showNavItems(['itemKey']);
// Badges
navigation.setCountBadge('itemKey', 5);
navigation.setDotBadge('itemKey');
navigation.clearBadge('itemKey');PrimitiveAppLayout, PrimitiveSidebarNav, PrimitiveBottomNav, and PrimitiveUserMenu all consume this store.
Breadcrumbs store: useBreadcrumbsStore
The breadcrumbs store maintains an array of breadcrumb segments derived from route metadata:
- State:
segments: BreadcrumbSegment[]– array of{ label, href }objects - Actions:
initialize({ router })– Wire to Vue Router (called bycreatePrimitiveApp)refreshCurrentRoute()– Re-generate breadcrumbs for the current route
Use refreshCurrentRoute() when other reactive state that influences breadcrumb generators changes (e.g., user display name).
User store: useUserStore
useUserStore wires your app into js-bao auth and user profile management.
State:
| Property | Type | Description |
| ----------------- | --------------------- | ----------------------------- |
| currentUser | UserProfile \| null | Current user profile |
| isAuthenticated | boolean | Whether user is authenticated |
| isAdmin | boolean (computed) | Whether user has admin role |
| isOnline | boolean | Network connectivity status |
| isInitialized | boolean | Whether store has initialized |
Actions:
| Method | Description |
| --------------------------------------------------- | ----------------------------------- |
| initialize({ loginUrl }) | Initialize auth and event listeners |
| login(continueURL?) | Start OAuth flow |
| handleOAuthCallback(defaultContinueUrl, loginUrl) | Process OAuth callback |
| logout(redirectTo?) | Log out and optionally redirect |
User preferences:
Preferences are stored via the internal UserPref js-bao model in a per-user root document:
const user = useUserStore();
// Get a preference with default value
const theme = user.getPref<string>("theme", "light");
// Set a preference
await user.setPref("theme", "dark");
// Delete a preference
await user.deletePref("theme");
// Get all preferences
const allPrefs = user.getAllPrefs();
// Clear all preferences
await user.clearAllPrefs();Document stores
Primitive App provides three document stores that work together to manage js-bao documents. Understanding their relationship is key to choosing the right pattern for your app.
How document stores relate
+---------------------------------------------+
| jsBaoDocumentsStore |
| (All accessible documents & invitations) |
+---------------------------------------------+
/ \
/ \
+------------------------+ +------------------------+
| singleDocumentStore | | multiDocumentStore |
| (One active doc) | | (Tag-based |
| | | collections) |
+------------------------+ +------------------------+jsBaoDocumentsStore: The foundational reactive store that tracks all documents the user has access to, plus pending invitations. It handles document metadata events from js-bao and maintains the authoritative list.singleDocumentStore: Built on top ofjsBaoDocumentsStore. Manages a single "active" document at a time. Ideal for simple apps.multiDocumentStore: Built on top ofjsBaoDocumentsStore. Manages multiple document collections based on tags. Ideal for complex apps with varied sharing needs.
Choosing a document store pattern
1. SingleDocument mode – Simple apps without sharing needs
Best for: Personal apps, simple tools, single-user data.
// appConfig.ts
documentStoreMode: DocumentStoreMode.SingleDocument;- Each user automatically gets a single document created for them.
- No document switching UI is shown.
- Data is conceptually like a single database per user.
2. SingleDocumentWithSwitching mode – Google Docs-like sharing pattern
Best for: Apps with clear data "containers" that users might share.
// appConfig.ts
documentStoreMode: DocumentStoreMode.SingleDocumentWithSwitching;- Users automatically get a default document created.
- Users can create additional documents, each of which can be shared with others.
- Only one document is active at a time.
- Built-in UI for document switching, sharing invitations, and management.
- Examples: One document per "company", splitting "personal" from "shared" data.
Configuration:
export function getSingleDocumentConfig() {
return {
userVisibleDocumentName: "Project",
userVisibleDocumentNamePlural: "Projects",
defaultDocumentTitle: "My First Project",
manageDocumentsRouteName: "projects-manage",
} as const;
}3. MultiDoc mode – Complex sharing and data modeling
Best for: Apps with complex sharing needs, multiple data sets, or large data volumes.
// appConfig.ts
documentStoreMode: DocumentStoreMode.MultiDoc;- Multiple documents can be open simultaneously.
- Documents are organized into "collections" based on tags.
- Collections are registered dynamically at runtime.
- Supports auto-opening documents and auto-accepting invitations per collection.
- Recommended when: sharing different data sets with different users, data exceeds ~10MB per document, need to search across multiple documents.
Usage:
import { useMultiDocumentStore } from "primitive-app";
const multiDoc = useMultiDocumentStore();
// Register a collection (typically on component mount)
await multiDoc.registerCollection({
name: "workspaces",
tag: "workspace",
autoOpen: true, // Auto-open matching documents
autoAcceptInvites: true, // Auto-accept invitations for this collection
});
// Access documents in a collection
const workspaces = computed(() => multiDoc.collections["workspaces"] ?? []);
// Check collection readiness
const isReady = multiDoc.getCollectionReadyRef("workspaces");
// Create a document in a collection
const newDoc = await multiDoc.createDocument("workspaces", "New Workspace");
// Unregister when done
await multiDoc.unregisterCollection("workspaces");jsBaoDocumentsStore API
The base store for all document operations:
State:
| Property | Type | Description |
| ---------------------- | ---------------------- | --------------------------- |
| documents | TrackedDocument[] | All accessible documents |
| pendingInvitations | DocumentInvitation[] | Pending sharing invitations |
| openDocumentIds | Set<string> | Currently open document IDs |
| documentListLoaded | boolean | Document list has loaded |
| invitationListLoaded | boolean | Invitation list has loaded |
| isReady | boolean (computed) | Both lists have loaded |
Actions:
| Method | Description |
| --------------------------------------------- | ------------------------------ |
| initialize() | Load documents and invitations |
| reset() | Clear state and unsubscribe |
| refreshDocuments() | Reload document list |
| openDocument(id) | Open a document |
| closeDocument(id) | Close a document |
| createDocument(title, tags?) | Create a new document |
| renameDocument(id, title) | Rename a document |
| deleteDocument(id) | Delete a document |
| shareDocument(id, email, permission) | Share with another user |
| refreshPendingInvitations() | Reload invitations |
| acceptInvitation(documentId) | Accept an invitation |
| declineInvitation(documentId, invitationId) | Decline an invitation |
singleDocumentStore API
Manages a single active document:
State:
| Property | Type | Description |
| ------------------------- | ------------------------- | -------------------------- |
| currentDocumentId | string \| null | Active document ID |
| currentDocumentMetadata | TrackedDocument \| null | Active document metadata |
| isReady | boolean | Document is open and ready |
| isCurrentDocReadOnly | boolean | User has read-only access |
Actions:
| Method | Description |
| --------------------- | ------------------------------ |
| initialize(options) | Initialize with config |
| reset() | Clear state |
| switchDocument(id) | Switch to a different document |
multiDocumentStore API
Manages multiple document collections:
State:
| Property | Type | Description |
| ------------- | ----------------------------------- | ------------------------------- |
| collections | Record<string, TrackedDocument[]> | Documents grouped by collection |
Actions:
| Method | Description |
| ----------------------------------- | ------------------------------- |
| registerCollection(config) | Register a new collection |
| unregisterCollection(name) | Unregister a collection |
| getCollection(name) | Get documents in a collection |
| isCollectionRegistered(name) | Check if collection exists |
| isCollectionReady(name) | Check if collection is ready |
| getCollectionReadyRef(name) | Get reactive readiness ref |
| createDocument(collection, title) | Create document in collection |
| isDocumentReady(id) | Check if document is open |
| getDocumentReadyRef(idRef) | Get reactive document readiness |
| reset() | Clear all collections |
PrimitiveAppLayout usage patterns
PrimitiveAppLayout is the main authenticated app shell. It:
- Renders a sidebar (desktop), breadcrumb header, and optional bottom nav (mobile).
- Integrates with:
useNavigationStore(sidebar items, bottom nav, back behavior).useBreadcrumbsStore(breadcrumbs and page title).useUserStore(user menu, online status).useAppConfigStoreandDocumentStoreMode(document switching vs static header).useSingleDocumentStore(optional single-document switcher).
- Automatically renders action components (dialogs/sheets) for nav items with
actionComponentdefined. - Shows a service worker "update required" banner and an optional worktree label banner (for dev workflows).
You can adopt it at different levels of complexity:
1) Use it as-is (configure via stores and route metadata)
This is the simplest path – use PrimitiveAppLayout as the root layout for authenticated routes, and configure:
- App config (
getAppConfig) - Document store (
getSingleDocumentConfig+DocumentStoreMode) - Navigation (
getNavigationConfig) - Routing meta (
primitiveRouterMeta)
Example (excerpt):
// src/router/routes.ts
const routes: RouteRecordRaw[] = [
{
path: "/",
component: PrimitiveAppLayout,
children: [
{
path: "",
name: "home",
component: () => import("@/pages/HomePage.vue"),
meta: {
primitiveRouterMeta: {
requireAuth: "member",
breadcrumb: { title: "Home" },
},
},
},
],
},
// login + static sections...
];You do not pass any props to PrimitiveAppLayout – it reads state from the shared stores.
2) Wrap it in a layout that performs extra data loading or dynamic tweaks
Wrap PrimitiveAppLayout in your own layout to:
- Run data loaders (
useJsBaoDataLoader, app-specific composables). - Dynamically modify navigation (
navigationStore.addNavItems, badges). - Add theming or additional chrome around the shell.
<!-- src/layouts/ThemedPrimitiveAppLayout.vue -->
<script setup lang="ts">
import { PrimitiveAppLayout, useNavigationStore } from "primitive-app";
import { onMounted } from "vue";
const navigation = useNavigationStore();
onMounted(() => {
navigation.addNavItems({
recentItems: {
key: "recentItems",
navTitle: "Recent Items",
navGroup: "main",
routeName: "recent-items",
mobilePriority: 2,
},
});
});
</script>
<template>
<div class="min-h-screen bg-background text-foreground">
<PrimitiveAppLayout>
<router-view />
</PrimitiveAppLayout>
</div>
</template>Routes now use ThemedPrimitiveAppLayout instead of PrimitiveAppLayout directly.
3) Override sidebar / bottom nav content via slots
PrimitiveAppLayout exposes:
#sidebar– replaces the entire sidebar area.#bottomNav– replaces the mobile bottom navigation bar.- Default slot – main content area (which typically renders
<router-view />).
This lets you keep the overall shell behavior (SW banner, worktree label, mobile back header, padding) while providing your own navigation components.
<!-- src/layouts/CustomShellLayout.vue -->
<script setup lang="ts">
import {
PrimitiveAppLayout,
PrimitiveUserMenu,
useNavigationStore,
useUserStore,
} from "primitive-app";
import { computed } from "vue";
const navigation = useNavigationStore();
const user = useUserStore();
const mainNavItems = computed(() => navigation.navMain);
const userMenuItems = computed(() => navigation.userMenuItems);
</script>
<template>
<PrimitiveAppLayout>
<template #sidebar>
<aside class="flex h-full flex-col border-r bg-background">
<header class="p-4 border-b">
<h1 class="text-sm font-semibold tracking-tight">
My Custom Sidebar
</h1>
</header>
<nav class="flex-1 overflow-y-auto p-2 space-y-1">
<RouterLink
v-for="item in mainNavItems"
:key="item.title"
:to="item.url"
class="block px-3 py-2 rounded-md text-sm hover:bg-accent"
>
{{ item.title }}
</RouterLink>
</nav>
<footer class="border-t p-2">
<PrimitiveUserMenu
:current-user="user.currentUser"
:is-online="user.isOnline"
:user-menu-items="userMenuItems"
/>
</footer>
</aside>
</template>
<template #bottomNav>
<nav class="fixed inset-x-0 bottom-0 border-t bg-background md:hidden">
<!-- your own mobile tab bar implementation -->
</nav>
</template>
<router-view />
</PrimitiveAppLayout>
</template>You can also reconstruct the default layout but insert custom elements (e.g. banners, diagnostics) inside the slots.
4) Build a completely custom layout
Finally, you can skip PrimitiveAppLayout entirely and build your own shell using the underlying stores and components:
useAppConfigStore,useUserStore,useNavigationStore,useBreadcrumbsStore,useSingleDocumentStore- Components:
PrimitiveSidebarNav,PrimitiveAppBreadcrumb,PrimitiveUserMenu,PrimitiveBottomNav,PrimitiveSingleDocumentSwitcher, etc.
<!-- src/layouts/MyFullyCustomLayout.vue -->
<script setup lang="ts">
import {
PrimitiveAppBreadcrumb,
PrimitiveSidebarNav,
PrimitiveUserMenu,
useAppConfigStore,
useNavigationStore,
useUserStore,
} from "primitive-app";
const appConfig = useAppConfigStore();
const navigation = useNavigationStore();
const user = useUserStore();
</script>
<template>
<div class="min-h-screen grid grid-cols-[240px_1fr]">
<aside class="border-r bg-background flex flex-col">
<header class="p-4 border-b">
<span class="font-semibold text-sm">
{{ appConfig.appName() }}
</span>
</header>
<main class="flex-1 overflow-y-auto">
<PrimitiveSidebarNav />
</main>
<footer class="border-t p-2">
<PrimitiveUserMenu
:current-user="user.currentUser"
:is-online="user.isOnline"
:user-menu-items="navigation.userMenuItems"
/>
</footer>
</aside>
<section class="flex flex-col">
<header class="h-12 flex items-center border-b px-4">
<PrimitiveAppBreadcrumb />
</header>
<main class="flex-1 overflow-y-auto p-4">
<router-view />
</main>
</section>
</div>
</template>This is useful if you have strong visual/UX requirements or are gradually adopting primitive-app in an existing application.
Data loading with useJsBaoDataLoader
useJsBaoDataLoader provides a standardized pattern for:
- Loading data asynchronously (typically via js-bao models).
- Watching a "document ready" gate (e.g., from
useSingleDocumentStore). - Reacting to changes in query params.
- Subscribing to js-bao models and automatically reloading when data changes.
- Integrating with skeleton components like
PrimitiveSkeletonGate.
API overview
import { useJsBaoDataLoader } from "primitive-app";
const { data, initialDataLoaded, reload } = useJsBaoDataLoader<Data, Query>({
subscribeTo: [
/* models with Model.subscribe(cb) */
],
queryParams, // ref/computed or plain value; null disables query-driven reloads
documentReady, // ref/computed/boolean gate
loadData: async ({ queryParams }) => {
/* ... */
},
pauseUpdates, // optional ref/computed/boolean
debounceMs: 50, // optional debounce for reloads
onError: (error) => {}, // optional error handler
});- Result:
data: Ref<Data | null>– last successfully loaded value.initialDataLoaded: Ref<boolean>– becomestrueafter first successful load whiledocumentReadyistrue.reload(): void– manually schedule a reload (respecting debounce and gates).
Example with js-bao models and the single-document store
// src/pages/ProductsPage.vue (script setup)
import {
PrimitiveSkeletonGate,
useJsBaoDataLoader,
useSingleDocumentStore,
} from "primitive-app";
import { Product } from "@/models/Product";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
type PageData = { products: Product[] };
const singleDoc = useSingleDocumentStore();
const { isReady: documentReady } = storeToRefs(singleDoc);
const queryParams = ref({}); // "load all" for this example
const { data, initialDataLoaded, reload } = useJsBaoDataLoader<PageData>({
subscribeTo: [Product],
queryParams,
documentReady,
loadData: async () => {
const result = await Product.query(queryParams.value);
return { products: (result.data || []) as Product[] };
},
onError: (err) => {
console.error("Error loading products", err);
},
});
const products = computed(() => data.value?.products ?? []);<!-- template -->
<PrimitiveSkeletonGate :is-ready="initialDataLoaded">
<template #skeleton>
<!-- skeleton UI -->
</template>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }}
</li>
</ul>
</PrimitiveSkeletonGate>Example without js-bao (simple async data)
import { useJsBaoDataLoader, PrimitiveSkeletonGate } from "primitive-app";
import { ref } from "vue";
type DemoData = { message: string };
const simulateSlowLoad = async (): Promise<DemoData> => {
await new Promise((resolve) => setTimeout(resolve, 800));
return { message: "Loaded after a short delay." };
};
const { data, initialDataLoaded } = useJsBaoDataLoader<DemoData>({
subscribeTo: [],
queryParams: ref({}),
documentReady: ref(true),
loadData: simulateSlowLoad,
});Auth, navigation, documents, and debug components
Primitive App includes higher-level components that sit on top of the stores:
- Auth components
PrimitiveLogin,PrimitiveLogout,PrimitiveOauthCallback
- Navigation components
PrimitiveAppBreadcrumb,PrimitiveSidebarNav,PrimitiveBottomNav,PrimitiveUserMenu,PrimitiveNavigationBadge
- Document components
PrimitiveManageDocuments,PrimitiveShareDocumentDialog,PrimitiveSingleDocumentSwitcher
- Shared components
PrimitiveLogoSpinner,PrimitiveSkeletonGate,DeleteConfirmationDialog
- Debug suite
PrimitiveDebuggingSuite,PrimitiveTestRunner,DocumentDebugger,DebugSuiteLayout, plus debug pages you can mount on a/debugroute.
Debugging suite
The debugging suite provides tools for testing and inspecting your app during development:
Document Debugger
The Document Debugger is a powerful tool for inspecting and managing js-bao documents and model data:
- Document management: View all documents, create new documents, rename, delete, and switch between documents
- Model inspection: Browse all registered js-bao models and their records
- Record CRUD: Create, read, update, and delete records directly
- Filtering & sorting: Filter and sort records by any field
- Mass operations: Bulk delete records or documents
- Schema inspection: View model fields, types, indexes, relationships, and unique constraints
The Document Debugger uses its own document context independent of the app's main document stores, so you can inspect data without affecting the app state.
Test Runner
The test harness runs app-level integration tests. Tests are grouped into PrimitiveTestGroup objects and registered with the DebugSuiteLayout.
Defining test groups
Create a file for your test groups (e.g., src/tests/myTests.ts):
// src/tests/myTests.ts
import type { PrimitiveTestGroup } from "primitive-app";
import { useUserStore, useSingleDocumentStore } from "primitive-app";
// Helper to format pass/fail scores (the runner parses this format)
function formatScore(passed: number, total: number): string {
const percentage = total === 0 ? 100 : (passed / total) * 100;
return `${passed}/${total} (${percentage.toFixed(1)}%)`;
}
export const userPreferencesTestGroup: PrimitiveTestGroup = {
name: "User Preferences",
mode: "offline", // "offline" (default) or "online" – controls network requirement
tests: [
{
id: "user-pref-read-all",
name: "can read all user preferences without error",
async run(log?: (m: string) => void): Promise<string> {
const user = useUserStore();
log?.("Reading all preferences from userStore...");
const prefs = user.getAllPrefs();
log?.(`Retrieved ${Object.keys(prefs).length} preference(s)`);
// Returning a score string indicates success
return formatScore(1, 1);
},
},
{
id: "user-pref-default",
name: "getPref returns default value for missing key",
async run(log?: (m: string) => void): Promise<string> {
const user = useUserStore();
const defaultValue = "fallback";
log?.(`Requesting missing key with default "${defaultValue}"...`);
const value = user.getPref<string>("nonexistent_key", defaultValue);
// Throwing an Error fails the test
if (value !== defaultValue) {
throw new Error(`Expected "${defaultValue}", got "${value}"`);
}
log?.("Default value returned correctly");
return formatScore(1, 1);
},
},
],
};
export const documentTestGroup: PrimitiveTestGroup = {
name: "Document Store",
mode: "offline",
tests: [
{
id: "doc-store-ready",
name: "single document store becomes ready",
async run(log?: (m: string) => void): Promise<string> {
const docStore = useSingleDocumentStore();
log?.(`Document store ready: ${docStore.isReady}`);
if (!docStore.isReady) {
throw new Error("Expected document store to be ready");
}
return formatScore(1, 1);
},
},
],
};
// Export all test groups as an array
export const appTestGroups: PrimitiveTestGroup[] = [
userPreferencesTestGroup,
documentTestGroup,
];Test function conventions
id: Unique identifier for the test (used for selection state).name: Human-readable test name displayed in the UI.run(log?): Async function that executes the test.- Use
log?.("message")to output progress to the test runner. - Return a score string like
"1/1 (100.0%)"to indicate success (the runner parses this format). - Throw an
Errorto fail the test.
- Use
mode(on the group):"offline"(default) runs tests without requiring network;"online"requires network connectivity.
Registering tests with routes
Mount the debug suite on a /debug route and pass your test groups to DebugSuiteLayout:
// src/router/routes.ts
import {
createPrimitiveRouter,
DebugSuiteLayout,
DebuggingSuiteHome,
DebuggingSuiteTests,
DebuggingSuiteDocuments,
} from "primitive-app";
import { appTestGroups } from "@/tests/myTests";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
// ... your app routes ...
// Debug suite (admin-only recommended)
{
path: "/debug",
component: DebugSuiteLayout,
props: {
testGroups: appTestGroups,
appName: "My App",
},
meta: {
primitiveRouterMeta: {
requireAuth: "admin", // or "member" for broader access
},
},
children: [
{
path: "",
name: "debug-home",
component: DebuggingSuiteHome,
},
{
path: "test",
name: "debug-test",
component: DebuggingSuiteTests,
},
{
path: "documents",
name: "debug-documents",
component: DebuggingSuiteDocuments,
},
],
},
];
const router = createPrimitiveRouter({
routes,
loginRouteName: "login",
});
export default router;The debug suite provides:
- Home page (
DebuggingSuiteHome): Overview and quick actions. - Test page (
DebuggingSuiteTests): Select and run tests, view results and logs. - Documents page (
DebuggingSuiteDocuments): Inspect and manage js-bao documents via the Document Debugger.
js-bao integration & logging
JsBao client service
Primitive App centralizes js-bao client setup via:
initializeJsBao(config: JsBaoClientOptions)– called once at startup.jsBaoClientService.getClientAsync()– used internally by stores to access the client.
config.models from your app are merged with the library's internal models (such as UserPref) before initializing the client.
Logging
primitive-app ships with a scoped logging utility:
primitiveAppBaseLoggercreateLogger({ level, scope })LogLevel("debug" | "info" | "warn" | "error" | "none")
Example:
import { createLogger } from "primitive-app";
const logger = createLogger({
level: import.meta.env.DEV ? "debug" : "info",
scope: ["MyApp", "ProductsPage"],
});
logger.debug("Loaded products", { count: 42 });Reference and further examples
For a more complete, real-world example, refer to:
- Demo app (
primitive-app-demo)src/main.ts– bootstrapping withcreatePrimitiveAppsrc/router/routes.ts– routes +createPrimitiveRoutersrc/config/appConfig.ts,src/config/envConfig.ts,src/config/navigationConfig.tssrc/pages/GettingStarted*– focused pages on configuration, theming, routing, navigation, data loading, utilities
- Template app (
primitive-app-template)- Minimal starter following the same patterns, useful for new apps.
For a deeper, implementation-level map of the library (especially for automation or AI agents), see AGENTS.md.
