npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 (and js-bao models 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
  • 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:

  1. Initialize js-bao and the user store.
  2. Initialize app config.
  3. Wire the document store (single-document, multi-document, or none).
  4. Wire breadcrumbs to the router.
  5. Wire navigation to the router.
  6. 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-router instance (typically created via createPrimitiveRouter, see below).
  • getAppConfig: () => InitializeAppConfigOptions
  • getJsBaoConfig: () => JsBaoClientOptions (from js-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 documentStoreMode in your getAppConfig determines which document store(s) are initialized:

  • DocumentStoreMode.None: No document store is initialized.
  • DocumentStoreMode.SingleDocument or DocumentStoreMode.SingleDocumentWithSwitching: Requires getSingleDocumentConfig.
  • DocumentStoreMode.MultiDoc: Initializes jsBaoDocumentsStore on auth; apps register collections dynamically via multiDocumentStore.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.requireAuth is:
    • "none": route is public.
    • "member": user must be authenticated.
    • "admin": user must be authenticated and useUserStore().isAdmin === true.
  • For member/admin routes:
    • Unauthenticated users are redirected to the login route or URL with a continueURL query parameter.
    • Non-admin users on admin routes are redirected to the app's homeRouteName from useAppConfigStore.

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:

  1. Configuration stores – Static app configuration and UI state
  2. User store – Authentication and user preferences
  3. 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 initialized
  • DocumentStoreMode.SingleDocument – Single document per user, no switching UI
  • DocumentStoreMode.SingleDocumentWithSwitching – Single active document with switching/sharing UI
  • DocumentStoreMode.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;
}

createPrimitiveApp calls useAppConfigStore().initialize(getAppConfig()) during bootstrap and sets up a reactive document.title using 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, or action. When action is provided with actionComponent, 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 emit update:open to close
  • No custom layout wrapper is needed – PrimitiveAppLayout handles 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 by createPrimitiveApp)
    • 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 of jsBaoDocumentsStore. Manages a single "active" document at a time. Ideal for simple apps.
  • multiDocumentStore: Built on top of jsBaoDocumentsStore. 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).
    • useAppConfigStore and DocumentStoreMode (document switching vs static header).
    • useSingleDocumentStore (optional single-document switcher).
  • Automatically renders action components (dialogs/sheets) for nav items with actionComponent defined.
  • 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> – becomes true after first successful load while documentReady is true.
    • 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 /debug route.

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 Error to fail the test.
  • 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:

  • primitiveAppBaseLogger
  • createLogger({ 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 with createPrimitiveApp
    • src/router/routes.ts – routes + createPrimitiveRouter
    • src/config/appConfig.ts, src/config/envConfig.ts, src/config/navigationConfig.ts
    • src/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.