NNO Docs
Concepts

Feature Package SDK

Documentation for Feature Package SDK

Date: 2026-03-30 Status: Detailed Design Parent: System Architecture Package: @neutrino-io/sdk


Overview

The Feature Package SDK is the contract layer between the NNO console shell and any feature package — Type 1 (core), Type 2 (domain), or Type 3 (client-authored). It lives in packages/sdk and is the only package a feature author needs to import from the shell ecosystem.

Feature Package
  imports → @neutrino-io/sdk        (types, hooks, utilities)
  exports → FeatureDefinition    (consumed by console shell at bundle time)

The SDK has zero runtime dependencies on the shell itself. This keeps features independently testable and prevents coupling.


1. Core Types

1.1 FeatureDefinition

The primary export every feature package must provide:

// packages/sdk/src/feature/types.ts

export interface FeatureDefinition {
  // ── Identity ──────────────────────────────────────────────────────────────
  id: string; // Unique slug. Lowercase, hyphens only. e.g. 'analytics'
  version: string; // Semver. e.g. '1.0.0'
  displayName: string; // Human-readable. e.g. 'Analytics'
  description: string; // Short description shown in NNO Portal
  icon?: string; // Lucide icon name. e.g. 'bar-chart-2'

  // ── Shell Integration ──────────────────────────────────────────────────────
  routes: FeatureRoute[]; // Route definitions (see Section 1.2)
  navigation: FeatureNavItem[]; // Sidebar entries this feature contributes
  permissions: FeaturePermission[]; // Required permissions (see Section 3)

  // ── Backend ────────────────────────────────────────────────────────────────
  requiresService?: boolean; // true = feature needs a backend Worker provisioned
  serviceEnvKey?: string; // Env var name for service URL. e.g. 'VITE_ANALYTICS_API_URL'
  // Required when requiresService: true

  // ── Providers ─────────────────────────────────────────────────────────────
  providers?: FeatureProvider[]; // Context providers injected at shell level (see Section 4)

  // ── Lifecycle ─────────────────────────────────────────────────────────────
  onRegister?: (shell: ShellContext) => void; // Called once when shell loads feature
  onActivate?: (shell: ShellContext) => void; // Called when user navigates into feature
  onDeactivate?: (shell: ShellContext) => void; // Called when user navigates away
}

1.2 FeatureRoute

Route definitions use a plain config object; the shell translates these into TanStack Router routes at build time:

export interface FeatureRoute {
  path: string; // Relative to feature root. e.g. '/', '/detail/$id'
  component: ComponentType<Record<string, unknown>> | string; // Page component — pass the exported name as a string (e.g. 'BillingDashboard') or a direct ComponentType reference
  layout?: "default" | "full" | "blank" | "auth" | "minimal" | "error"; // Shell layout variant. Default: 'default'
  auth?: boolean; // Require authentication. Default: true
  permissions?: string[]; // Gate this specific route behind permissions
  meta?: {
    title?: string; // Browser tab title
    description?: string; // Meta description
  };
}

Path conventions:

  • / — feature root (e.g. /analytics)
  • /detail/$id — dynamic segment (TanStack Router $param syntax)
  • /settings — nested sub-page within the feature
  • Paths are registered exactly as declared — no shell-level prefix is added

1.3 FeatureNavItem

export interface FeatureNavItem {
  label: string;
  path: string; // Must match one of the declared routes
  icon?: string | ComponentType<{ size?: number; className?: string }>; // Icon (overrides feature default)
  order?: number; // Sidebar sort order. Lower = higher. Default: 100
  group?: string; // Sidebar group key, e.g. 'analytics', 'management'
  badge?: () => string | number | null; // Dynamic badge (unread count, status)
  children?: FeatureNavItem[]; // Nested sidebar items
  permissions?: string[]; // Permissions required to see this nav item
}

Features can declare nested sidebar items using children. The shell renders these as collapsible sub-menus:

// features/settings/src/feature.ts
navigation: [
  {
    label: "Settings",
    path: "/settings",
    icon: "Settings", // String icon name — resolved to lucide component by the shell
    order: 90,
    children: [
      { label: "Profile", path: "/settings/profile", icon: "User", order: 1 },
      {
        label: "Account",
        path: "/settings/account",
        icon: "UserCog",
        order: 2,
      },
      {
        label: "Appearance",
        path: "/settings/appearance",
        icon: "Palette",
        order: 3,
      },
      {
        label: "Notifications",
        path: "/settings/notifications",
        icon: "Bell",
        order: 4,
      },
    ],
  },
];

Icon resolution: The icon field accepts either a string name (e.g. 'User', 'Settings') or a React component reference. String names are resolved to lucide-react components by the shell's sidebar generator. This allows feature packages to specify icons without importing lucide-react directly.

Ordering: Children are sorted by order (ascending). Items without an order default to 99.

Permissions on nav items: Individual children can declare permissions?: string[] to conditionally show/hide specific sub-menu entries based on the user's permission set.

1.4 ShellContext

What the shell makes available to lifecycle hooks and the useShell() hook:

export interface ShellContext {
  platform: {
    id: string;
    name: string;
  };
  tenant: {
    id: string;
    name: string;
    parentId: string | null;
  };
  user: {
    id: string;
    email: string;
    name: string;
    permissions: string[];
  };
  features: {
    active: string[]; // IDs of all currently active features
    isActive: (id: string) => boolean;
  };
  navigate: (to: string) => void;
  env: Record<string, string>; // VITE_* env vars available to the feature
  // stack?: StackInstance; — Stack context injection into ShellContext is designed but not yet implemented in the SDK.
}

1.5 FeatureManifest (Build-Time Discovery)

In addition to FeatureDefinition, feature packages export a featureManifest for build-time auto-discovery:

// features/analytics/src/manifest.ts
import type { FeatureManifest } from "@neutrino-io/sdk/types";

export const featureManifest: FeatureManifest = {
  id: "analytics",
  name: "Analytics",
  package: "@neutrino-io/feature-analytics",
  enabledByDefault: true,
  loadPriority: 50,
  lazyLoad: true,
  domain: "analytics",
  type: "business",
  group: "Insights",
  defaultComponent: "AnalyticsDashboard",
};

The manifest is consumed at build time by the Vite feature discovery plugin. It replaces the need to manually register the feature in features.config.ts.

Key difference: FeatureDefinition describes what the feature does (routes, navigation, permissions). FeatureManifest describes how the shell should load it (priority, enabled state, domain).

FeatureResourceRequirements

Status: Designed, not yet implemented. FeatureResourceRequirements and a resources field on FeatureManifest are planned but not present in the current SDK (packages/sdk/src/types/feature-config.ts). The design below is retained for reference.

The resources block will declare which Cloudflare infrastructure this feature needs. The provisioning service will read this at activation time and create only the declared resources — nothing more:

// Planned — not yet in packages/sdk/src/types/feature-config.ts

export interface FeatureResourceRequirements {
  /** Deploy a CF Worker for this feature's backend API */
  worker?: boolean;
  /** Create a CF Pages project (SPA or portal) */
  pages?: boolean;
  /** Create a D1 SQLite database */
  d1?: boolean;
  /** Create an R2 object storage bucket */
  r2?: boolean;
  /** Create a KV namespace (cache, config, session) */
  kv?: boolean;
  /** Create a Cloudflare Queue */
  queue?: boolean;
  /**
   * Minimum billing plan required to activate this feature.
   * The registry checks this via the billing quota API before
   * enqueueing the activation job. Omit for features available on all plans.
   */
  minimumPlan?: "starter" | "growth" | "scale";
}

Examples by feature type:

Featureresources declaration
Auth-only (no backend)\{\} or omit resources
SPA portal\{ pages: true, minimumPlan: 'starter' \}
Analytics (DB + API)\{ d1: true, worker: true, minimumPlan: 'growth' \}
File storage\{ r2: true, kv: true, minimumPlan: 'growth' \}
Full-stack feature\{ d1: true, r2: true, worker: true, minimumPlan: 'scale' \}

Omitting resources (or leaving it empty) means the feature has no infrastructure footprint — it runs entirely client-side against existing services (e.g. @neutrino-io/feature-settings).

Feature packages must also declare the discovery marker in their package.json:

{
  "neutrino": {
    "type": "feature"
  }
}

The featureManifest must be exported from the package barrel (src/index.ts) so the Vite plugin can import it when generating the virtual:feature-registry module.

Two required exports per feature package:

  • \{id\}FeatureDefinition — the runtime contract (routes, navigation, permissions); consumed by the shell at runtime
  • featureManifest — the build-time discovery metadata; consumed by the Vite plugin during bundle generation

1.6 Stack Types

The SDK exports four stack-related types for use by features operating within a stack context. These are exported from @neutrino-io/sdk/types.

StackFeatureRef

export interface StackFeatureRef {
  featureId: string; // NNO feature ID, e.g. 'billing', 'analytics'
  required: boolean; // false = stack activates without this feature if unavailable
  config?: Record<string, unknown>; // Feature-level overrides within stack context
}

StackResourceRequirements

export interface StackResourceRequirements {
  sharedD1?: boolean; // Provision one D1 shared across all stack features
  sharedR2?: boolean; // Provision one R2 bucket shared across stack features
  sharedKV?: boolean; // Provision one KV namespace shared across stack features
  worker?: boolean; // Deploy a stack-level orchestration Worker
  pages?: boolean; // CF Pages project for the stack's front-end
  queue?: boolean; // Message queue for the stack
  minimumPlan?: "starter" | "growth" | "scale";
}

StackDefinition

NNO operators use this type to define stack templates published to services/stack-registry:

export interface StackDefinition {
  id: string; // kebab-case, e.g. 'saas-starter'
  version: string; // semver
  displayName: string; // e.g. 'SaaS Starter Stack'
  description: string;
  icon?: string; // Lucide icon name
  features: StackFeatureRef[]; // Ordered list of features in this stack
  resources: StackResourceRequirements;
  permissions: string[]; // Stack-level gates, e.g. ['saas-starter:access']
  requiresService?: boolean; // Deploy a stack-level orchestration Worker
  serviceEnvKey?: string; // Env var for the orchestration Worker URL
}

StackManifest

Build-time discovery contract for stacks (used by the shell's Vite plugin):

export interface StackManifest {
  id: string;
  name: string; // Display name
  version: string; // Semver
  description: string;
  icon?: string;
  features: StackFeatureRef[];
  resources: StackResourceRequirements;
  domain: string; // Business domain grouping
  type: "system" | "business";
  group: string; // Sidebar group heading
}

StackInstance

Runtime context injected into ShellContext when a feature is activated within a stack:

export interface StackInstance {
  id: string; // stack instance ID (from registry)
  stackId: string; // template ID or 'local' for platform-local
  name: string;
  version: string;
  isLocal: boolean;
  sharedResources: {
    d1Id?: string;
    r2Name?: string;
    kvId?: string;
    workerName?: string;
  };
}

Feature components can access the stack context via useShell().stack:

import { useShell } from '@neutrino-io/sdk/feature';

function MyPage() {
  const shell = useShell();

  if (shell?.stack) {
    // Feature is running in a stack context
    const stackId = shell.stack.id;
    const sharedD1 = shell.stack.sharedResources.d1Id;
  }

  return <div>...</div>;
}

See stacks.md for the full Stack architecture specification.


2. SDK Hooks

Features consume shell context through these hooks. All hooks are provided by the SDK and backed by context injected by the shell.

2.1 useShell()

Access the full shell context. Returns ShellContext | nullnull when called outside a shell-provided context (e.g. in unit tests without a ShellContextProvider). Always guard before destructuring:

import { useShell } from '@neutrino-io/sdk/feature';

function MyPage() {
  const shell = useShell();
  if (!shell) return null;  // outside shell context — e.g. standalone test
  const { platform, tenant, user, navigate } = shell;
  return <div>Platform: {platform.name}, Tenant: {tenant.name}</div>;
}

2.2 useFeaturePermission(permission)

Check if the current user holds a specific permission. Use this hook to conditionally render UI elements within a feature component:

import { useFeaturePermission } from '@neutrino-io/sdk/feature';

function AnalyticsExportButton() {
  const canExport = useFeaturePermission('analytics:export');
  if (!canExport) return null;
  return <button>Export</button>;
}

This hook reads from the session's permissions array (injected by the shell via ShellContextProvider). Returns true if the user holds the permission OR holds '*' (platform-admin wildcard). Returns false otherwise — it never throws.

2.3a useHasPermission(permission) — settings/auth context

@neutrino-io/sdk/hooks/auth also exports useHasPermission() for use in settings and auth-adjacent feature components. It reads from SettingsAuthContext rather than ShellContext and is used by the settings feature internally:

import { useHasPermission } from '@neutrino-io/sdk/hooks/auth';

function AdminSection() {
  const canAdmin = useHasPermission('settings:admin');
  if (!canAdmin) return null;
  return <AdminPanel />;
}

Use useFeaturePermission() for feature components. Use useHasPermission() only when building settings-adjacent UI that integrates with the settings auth context.

2.3 useFeatureEnv()

Resolve environment variables injected by the shell:

import { useFeatureEnv } from "@neutrino-io/sdk/feature";

function useAnalyticsClient() {
  const env = useFeatureEnv();
  const baseUrl = env["VITE_ANALYTICS_API_URL"];
  return createApiClient(baseUrl);
}

3. Permission Model

3.1 Declaration

Every feature declares its permissions in FeatureDefinition.permissions. The shell will not activate the feature for a user who lacks all required permissions.

export type FeaturePermission = string; // format: '{feature-id}:{action}', e.g. 'analytics:read'

Standard Permission Format: All permission keys follow \{feature-id\}:\{action\} — the feature's own id as the namespace, colon, then a lowercase-hyphenated action name. Examples: analytics:read, billing:manage, zero:platform-manage. This format is enforced by nno feature validate (rule V06: must match ^\{id\}:[a-z-]+$).

Example — analytics feature:

permissions: [
  'analytics:read',    // gates entire feature — user must have this to access
  'analytics:export',  // optional — checked per UI element via useFeaturePermission()
  'analytics:admin',   // optional — conditional access to admin UI
],

3.2 Permission Hierarchy

Permissions are assigned at the NNO IAM layer and flow down:

Platform Admin
  → can grant/revoke all permissions for all tenants under this platform

Tenant Admin
  → can grant/revoke permissions for users within their tenant only

User
  → holds an explicit set of permission keys
  → evaluated at shell boot (embedded in JWT claims or fetched from auth service)

3.3 Shell Gate Behaviour

ScenarioShell Behaviour
User missing a required permissionFeature routes return 403, feature removed from sidebar
User missing an optional permissionFeature loads normally; feature uses useFeaturePermission() to conditionally render UI
Feature not in tenant's active feature listFeature is not bundled into the shell (build-time exclusion)

4. Provider Injection

Clarification — two distinct provider patterns:

  1. Shell providers (e.g. ShellContextProvider, SettingsAuthContextProvider) — provided by the console shell at app root. Features consume these via SDK hooks (useShell(), useHasPermission()). Feature packages should never create their own shell-level providers.
  2. Feature providers (this section) — provided by feature packages for their own internal state. These are injected by the shell into the router outlet tree so a feature's nested routes can share state without prop-drilling.

Features use approach 1 (hooks) to read shell data. Features declare approach 2 (providers) only when they need their own global state shared across their own sub-routes.

Features can inject React context providers at the shell level — useful for feature-internal global state that needs to be accessible across nested routes within a feature:

export interface FeatureProvider {
  key: string; // unique identifier for this provider
  component: ComponentType<{ children: React.ReactNode }>;
}

Example:

// analytics feature — provides a global date range context (feature-internal state)
import { DateRangeProvider } from "./providers/date-range";

const feature: FeatureDefinition = {
  id: "analytics",
  // ...
  providers: [{ key: "date-range", component: DateRangeProvider }],
};

The shell wraps the router outlet with all active feature providers on startup:

ShellProviders (shell-owned — features consume via hooks)
  └── FeatureProvider (analytics/DateRangeProvider — feature-owned internal state)
      └── FeatureProvider (billing/BillingContextProvider — feature-owned internal state)
          └── RouterOutlet

5. nno.config.ts — Feature Manifest File

Designed, not yet available.

Every feature package includes an nno.config.ts at the root. This is the source of truth for the NNO CLI and Marketplace:

// nno.config.ts
import { defineFeatureConfig } from "@neutrino-io/sdk/config";

export default defineFeatureConfig({
  // ── Marketplace Identity ──────────────────────────────────────────────────
  id: "analytics", // Must match FeatureDefinition.id
  version: "1.0.0", // Must match package.json version
  author: "@acme", // npm scope of the publishing client
  license: "MIT",

  // ── Service Configuration ─────────────────────────────────────────────────
  service: {
    name: "analytics", // Worker service name (used in wrangler.toml template)
    framework: "hono", // 'hono' | 'none'
    hasDatabase: true, // Whether a D1 database should be provisioned
    hasMigrations: true, // Whether to run migrations on activation
    migrationsDir: "service/migrations",
  },

  // ── Compatibility ─────────────────────────────────────────────────────────
  compatibility: {
    shellVersion: ">=1.0.0", // Minimum shell version required
    requires: [], // Other feature IDs this feature depends on
    conflicts: [], // Feature IDs that cannot be active at the same time
  },

  // ── Local Dev ─────────────────────────────────────────────────────────────
  dev: {
    port: 5180, // Vite dev port for feature UI (must not conflict)
    servicePort: 8790, // Wrangler dev port for feature Worker
    mockShellPort: 5100, // Port of the NNO mock shell
  },
});

6. Scaffold Template (nno init)

When nno init my-feature is run, the CLI generates this structure:

my-feature/
├── src/
│   ├── feature.ts             ← FeatureDefinition export (main SDK entry)
│   ├── index.tsx              ← Package entry point (re-exports feature.ts)
│   ├── routes/
│   │   ├── index.tsx          ← Feature home page (/my-feature)
│   │   └── detail.$id.tsx     ← Example detail page (/my-feature/detail/:id)
│   ├── components/            ← Feature-local UI components
│   ├── hooks/                 ← Feature-local hooks (data fetching, etc.)
│   ├── providers/             ← Optional: context providers declared in feature.ts
│   └── types.ts               ← Feature-local TypeScript types

├── service/                   ← Backend Worker (only if requiresService: true)
│   ├── src/
│   │   ├── index.ts           ← Hono app entry
│   │   ├── routes/
│   │   │   └── my-feature.ts  ← Route handlers
│   │   └── db/
│   │       ├── schema.ts      ← Drizzle schema
│   │       └── index.ts       ← DB client export
│   ├── migrations/
│   │   └── 0001_init.sql      ← Initial schema migration
│   └── wrangler.toml          ← Template — IDs filled by NNO Provisioning on activation

├── __tests__/
│   ├── feature.test.ts        ← FeatureDefinition contract tests
│   ├── routes.test.tsx        ← Route component tests
│   └── service.test.ts        ← Worker handler tests (if service)

├── nno.config.ts              ← NNO feature manifest
├── package.json
├── tsconfig.json
└── README.md

Generated src/feature.ts

import type { FeatureDefinition } from "@neutrino-io/sdk";
import IndexPage from "./routes/index";
import DetailPage from "./routes/detail.$id";

export const feature: FeatureDefinition = {
  id: "my-feature",
  version: "1.0.0",
  displayName: "My Feature",
  description: "A short description of what this feature does.",
  icon: "layout-dashboard",

  routes: [
    {
      path: "/",
      component: IndexPage,
      meta: { title: "My Feature" },
    },
    {
      path: "/detail/$id",
      component: DetailPage,
      permissions: ["my-feature:read"],
    },
  ],

  navigation: [
    {
      label: "My Feature",
      path: "/",
      icon: "layout-dashboard",
      order: 50,
    },
  ],

  permissions: ["my-feature:read"],

  requiresService: true,
  serviceEnvKey: "VITE_MY_FEATURE_API_URL",
};

export default feature;

7. Testing Utilities

Designed, not yet available.

The SDK ships test helpers so features can be tested in isolation without a real shell:

import {
  renderWithShell,
  createMockShellContext,
  mockPermissions,
} from '@neutrino-io/sdk/testing';

// Basic render with default mock shell context
test('renders feature home page', () => {
  const { getByText } = renderWithShell(<IndexPage />);
  expect(getByText('My Feature')).toBeInTheDocument();
});

// Custom shell context
test('hides export button without permission', () => {
  const ctx = createMockShellContext({
    user: {
      permissions: ['my-feature:read'],
      // 'my-feature:export' NOT included
    },
  });

  const { queryByText } = renderWithShell(<IndexPage />, { context: ctx });
  expect(queryByText('Export')).not.toBeInTheDocument();
});

// Mock service URL
test('calls analytics API with correct base URL', async () => {
  const ctx = createMockShellContext({
    env: { VITE_MY_FEATURE_API_URL: 'http://localhost:8790' },
  });

  // ... test API calls
});

8. Contract Validation (compile-time + runtime)

Designed, not yet available.

The SDK exports a Zod schema for FeatureDefinition so validation can be run both at development time (via nno validate) and at shell load time:

import { featureDefinitionSchema } from "@neutrino-io/sdk/validation";

// Runtime guard in shell
const result = featureDefinitionSchema.safeParse(feature);
if (!result.success) {
  console.error(
    `Invalid FeatureDefinition for feature '${feature?.id}':`,
    result.error,
  );
  // Feature is skipped, not loaded into shell
}

Validated rules:

  • id matches ^[a-z][a-z0-9-]*$ (lowercase, no leading digit)
  • version is valid semver
  • permissions entries match ^\{id\}:[a-z-]+$ (scoped to feature ID)
  • serviceEnvKey is present and matches ^VITE_[A-Z_]+_API_URL$ when requiresService: true
  • No duplicate route paths
  • No route path starting with /\{id\} (shell prefixes automatically)
  • navigation[].path values all exist in routes[].path

9. Package Structure (packages/sdk)

packages/sdk/
├── src/
│   ├── index.ts            ← Main entry: types + hooks
│   ├── feature/            ← FeatureDefinition, hooks, context
│   │   ├── index.ts
│   │   ├── types.ts        ← FeatureDefinition, FeatureRoute, etc.
│   │   ├── hooks.ts        ← useShell, useFeaturePermission, useFeatureEnv
│   │   └── context.tsx     ← ShellContextProvider (used by shell, not features)
│   ├── utils/              ← Shared utility functions
│   │   └── index.ts        ← cn, formatDate, formatDateTime, etc.
│   ├── hooks/              ← General hooks
│   │   └── index.ts
│   ├── hooks/auth/         ← Auth-related hooks
│   │   └── index.ts        ← useSettingsAuth, useHasPermission, etc.
│   ├── types/              ← Shared TypeScript types
│   │   └── index.ts
│   └── constants/          ← Shared constants (reserved for future use)
│       └── index.ts

├── package.json
└── tsconfig.json

package.json exports:

{
  "exports": {
    ".": { "types": "...", "import": "...", "require": "..." },
    "./feature": { "types": "...", "import": "...", "require": "..." },
    "./utils": { "types": "...", "import": "...", "require": "..." },
    "./hooks": { "types": "...", "import": "...", "require": "..." },
    "./hooks/auth": { "types": "...", "import": "...", "require": "..." },
    "./types": { "types": "...", "import": "...", "require": "..." },
    "./constants": { "types": "...", "import": "...", "require": "..." }
  }
}

Note: ./config and ./testing are not implemented yet. Stack types (StackDefinition, StackManifest, StackFeatureRef, StackResourceRequirements, StackInstance) are exported from ./types.


9a. Utility Exports (@neutrino-io/sdk/utils)

The ./utils subpath exports general-purpose utility functions available to all feature packages:

import {
  cn,
  formatDate,
  formatDateTime,
  formatCategory,
  truncateText,
  debounce,
} from "@neutrino-io/sdk/utils";
import { generateId } from "@neutrino-io/core/naming"; // ← generateId lives in @neutrino-io/core, not sdk

// cn — merge Tailwind class names (wraps clsx + tailwind-merge)
const className = cn("base-class", isActive && "active-class", props.className);

// formatDate — format a date value to a locale string
const label = formatDate(new Date());

// formatDateTime — format a date with time component
const label = formatDateTime(new Date());

// formatCategory — format a category string for display
const display = formatCategory("some_category_key");

// truncateText — truncate a string to a max length with ellipsis
const short = truncateText(longString, 80);

// debounce — debounce a function call
const debouncedSearch = debounce(handleSearch, 300);

// generateId is NOT exported from @neutrino-io/sdk/utils.
// Always import directly from @neutrino-io/core/naming:
//   import { generateId } from '@neutrino-io/core/naming'
const id = generateId();

10. Type Boundary: FeatureDefinition vs FeatureConfig

The Two-Type Model

There are two separate type concerns in the feature loading system. Understanding their boundary is essential for both feature authors and shell maintainers.

TypeLives inPerspectivePurpose
FeatureDefinitionpackages/sdkFeature packageWhat a feature declares about itself — its identity, routes, permissions, providers, lifecycle hooks
FeatureConfigpackages/sdk (shell types)Shell orchestrationHow the shell loads and manages a feature — loading strategy, environment gating, domain classification

FeatureDefinition — The Feature's Contract

FeatureDefinition contains only what a feature knows about itself. It has no awareness of shell loading mechanics:

// packages/sdk — owned by the feature package author
interface FeatureDefinition {
  id;
  version;
  displayName;
  description;
  icon; // identity
  routes;
  navigation;
  permissions; // shell contributions
  requiresService;
  serviceEnvKey; // backend binding
  providers?; // context injection
  onRegister?;
  onActivate?;
  onDeactivate?; // lifecycle
  // NO: package, module, enabled, domain, lazyLoad, loadPriority, environment
}

FeatureConfig — The Shell's Orchestration Wrapper

FeatureConfig wraps FeatureDefinition with the shell's own loading and management fields:

// packages/sdk (shell types) — owned by the shell
// Source: packages/sdk/src/types/feature-config.ts
interface FeatureConfig
  extends Partial<Omit<FeatureDefinition, "id" | "navigation">>,
    ShellOrchestrationFields {
  // Required identity (not optional even though FeatureDefinition fields are Partial here)
  id: string;
}

// ShellOrchestrationFields — the shell-only fields added on top of FeatureDefinition.
// Feature packages must NOT include these; they are shell concerns only.
interface ShellOrchestrationFields {
  package: string; // npm package name. e.g. '@neutrino-io/feature-analytics'
  module: string; // Export path within the package. e.g. 'features/analytics'
  enabled: boolean; // Whether the shell should activate this feature
  domain: string; // Shell categorisation. e.g. 'core' | 'billing' | 'zero'
  lazyLoad?: boolean; // Whether to code-split the feature
  loadPriority?: number; // Relative load order (lower = earlier)
  environment?: "development" | "production" | "staging" | "all";
}

Composition in features.config.ts

The shell's features.config.ts composes FeatureConfig by taking each package's exported FeatureDefinition and extending it with shell-specific fields:

// apps/console/src/config/features.config.ts
import type { FeatureConfig } from "@neutrino-io/sdk/types";
import { analyticsFeatureDefinition } from "@neutrino-io/ui-analytics";

export const features: FeaturesConfig = {
  analytics: {
    ...analyticsFeatureDefinition, // ← spreads FeatureDefinition from the package
    package: "@neutrino-io/ui-analytics",
    module: "AnalyticsFeature",
    enabled: true,
    domain: "analytics",
    lazyLoad: true,
    loadPriority: 30,
  },
};

Impact on Feature Package Authors

Feature packages (ui-auth, feature-settings, feature-billing, client packages) should:

  1. Import FeatureDefinition from @neutrino-io/sdk
  2. Export a named *FeatureDefinition constant (e.g. analyticsFeatureDefinition)
  3. Import FeatureManifest from @neutrino-io/sdk/types and export a featureManifest constant for build-time auto-discovery (see Section 1.5)
  4. Add "neutrino": \{"type": "feature"\} to their package.json to opt in to auto-discovery
  5. Never import FeatureConfig from shell-internal types — that type is shell-internal
// packages/ui-analytics/src/feature.ts — correct pattern
import type { FeatureDefinition } from '@neutrino-io/sdk'

export const analyticsFeatureDefinition: FeatureDefinition = {
  id: 'analytics',
  version: '1.0.0',
  displayName: 'Analytics',
  description: 'Usage analytics and reporting',
  icon: 'bar-chart-2',
  routes: [...],
  navigation: [...],
  permissions: ['analytics:read'],
  requiresService: true,
  serviceEnvKey: 'VITE_ANALYTICS_API_URL',
}

Migration from FeatureConfig Omit Pattern

All known packages that previously used Omit<FeatureConfig, 'package' | 'module'> as a workaround have been migrated to FeatureDefinition:

PackageStatus
features/settings✅ Migrated — imports FeatureDefinition from @neutrino-io/sdk/feature
features/*✅ Migrated — feature configs typed as FeatureDefinition
packages/ui-auth✅ Migrated — authFeatureDefinition in src/features/index.ts typed as FeatureDefinition; AuthFeatureConfig in src/types/feature-config.ts is a separate internal auth-form config type, not a shell registry type

11. Versioning & Compatibility

The SDK follows semver. The shell declares a minimum SDK version it supports; features declare the minimum shell version they require (via nno.config.ts compatibility.shellVersion).

Change TypeVersion BumpImpact
New optional field on FeatureDefinitionMinorBackward-compatible
New required field on FeatureDefinitionMajorFeatures need update
New hook added to SDKMinorBackward-compatible
Hook signature changeMajorFeatures need update
New validation ruleMinor (with deprecation warning first)Features may need update

Breaking SDK changes will include a migration guide and a compatibility shim for one major version.


13. Stack Integration

Features run in two modes: standalone (each feature provisions its own Cloudflare resources) and stack (a group of features shares a single set of Cloudflare resources provisioned once for the stack). This section covers how a feature detects and uses stack context.

Cross-reference: stacks.md for the full Stack architecture, StackDefinition, and lifecycle.

13.1 Stack Context in ShellContext

Not yet implemented. Stack context injection into ShellContext is designed but not yet present in the SDK. The stack field does not currently exist on ShellContext. The design below documents the intended future behaviour once this is implemented.

When implemented, useShell().stack will return a StackInstance when a feature is activated as part of a stack instance, and will be undefined when the feature is activated standalone.

// packages/sdk/src/types/stack.ts (from @neutrino-io/sdk/types)
// Target type definition — not yet wired into ShellContext

export interface StackInstance {
  id: string; // Stack instance ID (from Registry)
  stackId: string; // Template ID (e.g. 'saas-starter') or 'local' for platform-local stacks
  name: string;
  version: string;
  isLocal: boolean;
  sharedResources: {
    d1Id?: string; // CF D1 database ID
    r2Name?: string; // CF R2 bucket name
    kvId?: string; // CF KV namespace ID
    workerName?: string;
  };
}

13.2 Detecting Stack Context in the UI (Planned)

Not yet implemented. The following pattern will work once ShellContext.stack is added to the SDK.

import { useShell } from "@neutrino-io/sdk/hooks";

export function BillingDashboard() {
  const shell = useShell();

  if (shell.stack) {
    // Feature is running inside a stack instance
    console.log("Stack:", shell.stack.stackId, "Instance:", shell.stack.id);
  } else {
    // Feature is activated standalone
  }
}

13.3 Detecting Stack Context in a Feature Worker

A feature Worker detects stack context by checking for the STACK_DB binding injected by the NNO Provisioning service at activation time:

// In your feature Worker (e.g. services/billing or a feature Worker)
export default {
  async fetch(request: Request, env: Env) {
    if (env.STACK_DB) {
      // Running in stack context — use shared D1
      const db = env.STACK_DB;
      // Tables are namespaced by feature: billing__subscriptions, billing__invoices
    } else {
      // Standalone activation — use feature-specific D1
      const db = env.DB;
    }
  },
};

13.4 Resource Binding Names

The following bindings are injected by NNO Provisioning when activating a feature within a stack. They are in addition to the feature's own bindings (e.g. DB, STORAGE):

BindingAvailable whenDescription
STACK_DBStackResourceRequirements.sharedD1: trueShared D1 database for the entire stack
STACK_STORAGEStackResourceRequirements.sharedR2: trueShared R2 bucket for the entire stack
STACK_KVStackResourceRequirements.sharedKV: trueShared KV namespace for the entire stack
STACK_IDAlways, when in stackStack instance ID string
STACK_TEMPLATE_IDAlways, when in stackStack template ID (e.g. saas-starter)

When STACK_DB is present, all tables in that D1 must be prefixed with \{featureId\}__ to avoid collisions between features sharing the same database:

-- billing feature tables in the shared stack D1
CREATE TABLE billing__subscriptions ( ... );
CREATE TABLE billing__invoices ( ... );

-- analytics feature tables in the same D1
CREATE TABLE analytics__events ( ... );

13.5 Per-Feature Config Overrides via StackFeatureRef

Stack templates can pass per-feature config overrides through StackFeatureRef.config. These are surfaced in the feature Worker as env vars (prefixed with STACK_CONFIG_) at activation time:

// In the stack template (published to services/stack-registry)
const saasStarterStack: StackDefinition = {
  features: [
    {
      featureId: "billing",
      required: true,
      config: {
        trialDays: 14,
        defaultPlan: "growth",
      },
    },
  ],
  // ...
};

// In the billing Worker
const trialDays = Number(env.STACK_CONFIG_TRIAL_DAYS ?? 0);
const defaultPlan = env.STACK_CONFIG_DEFAULT_PLAN ?? "starter";

13.6 Stack-Awareness Declaration

Features do not need to declare stack-awareness in their FeatureDefinition or FeatureManifest. Stack bindings (STACK_DB, STACK_KV, etc.) are injected transparently by the provisioning service. A feature Worker should be written to handle both the presence and absence of stack bindings — this makes it independently activatable (standalone) and stack-compatible without code changes.


Status: Detailed design — FeatureManifest auto-discovery pattern implemented (2026-02-26); Stack types added 2026-02-28 Implementation target: packages/sdk/


12. UI Component Standards

Date added: 2026-03-01 Applies to: All feature packages in features/* and packages/ui-*

Feature packages are responsible for their own UI. This section defines the mandatory UI component standards that all NNO feature packages must follow to ensure visual consistency, dark mode compatibility, and maintainability.


12.1 Component Source

All UI components come from a single source: @neutrino-io/ui-core.

// ✅ Correct — single barrel import
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
  Badge,
  Button,
  Separator,
  Skeleton,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Input,
  Label,
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
  Textarea,
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@neutrino-io/ui-core";

// Layout + feedback primitives — also from @neutrino-io/ui-core
import {
  PageHeader, // Page-level header (title, description, action, breadcrumbs)
  EmptyState, // Empty data placeholder with icon + CTA
  PageSkeleton, // Full-page loading skeleton
  CardGridSkeleton, // Grid of card skeletons
  ConfirmDialog, // Pre-built confirmation dialog
} from "@neutrino-io/ui-core";

@neutrino-io/ui-core re-exports every shadcn/ui component plus NNO-specific layout primitives (PageHeader, EmptyState, ConfirmDialog, etc.). Feature packages must not install shadcn directly.


12.2 Styling — No Inline Styles

Feature packages must never use inline style=\{\{...\}\} objects or hardcoded hex/rgb values.

All styling uses Tailwind CSS utility classes with the project's semantic CSS variable tokens:

❌ Banned✅ Correct
style=\{\{ color: '#dc2626' \}\}className="text-destructive"
style=\{\{ color: '#64748b' \}\}className="text-muted-foreground"
style=\{\{ background: '#f8fafc' \}\}className="bg-muted"
style=\{\{ padding: '1.5rem' \}\}className="p-6"
style=\{\{ border: '1px solid #e2e8f0' \}\}className="border"
style=\{\{ borderRadius: '8px' \}\}className="rounded-lg"
style=\{\{ display: 'flex', gap: '1rem' \}\}className="flex gap-4"
style=\{\{ fontFamily: 'monospace' \}\}className="font-mono"

Semantic color tokens (dark mode compatible — always use these):

TokenPurpose
text-foregroundPrimary text
text-muted-foregroundSecondary / subdued text
text-destructiveError / danger text
bg-backgroundPage background
bg-cardCard surface
bg-mutedSubtle surface (code blocks, placeholders)
borderDefault border
border-destructiveError / danger border

12.3 Page Layout Anatomy

Every page component in a feature package must follow this structure:

<div className="space-y-6">
  <PageHeader />           ← always first
  [optional search/filter row]
  [main content: Card(s), Table(s), or EmptyState]
  [optional dialogs]
</div>

List pages:

export function ItemList() {
  const { data, isLoading, isError, error } = useItems(gatewayUrl)
  const [createOpen, setCreateOpen] = useState(false)

  if (isLoading) return <div className="space-y-2">
    {Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
  </div>

  if (isError) return <Card><CardContent className="pt-6">
    <p className="text-sm text-destructive">{error?.message}</p>
  </CardContent></Card>

  return (
    <div className="space-y-6">
      <PageHeader
        title="Items"
        description="Manage your items."
        action={{ label: 'New Item', icon: PlusIcon, onClick: () => setCreateOpen(true) }}
      />
      <Card>
        <CardContent className="p-0">
          <Table>...</Table>
        </CardContent>
      </Card>
      <CreateItemDialog open={createOpen} onOpenChange={setCreateOpen} />
    </div>
  )
}

Detail pages add breadcrumbs:

<PageHeader
  title={item.name}
  description={item.id}
  breadcrumbs={[
    { label: 'Items', onClick: () => window.location.pathname = '/feature/items' },
    { label: item.name },
  ]}
/>

12.4 State Handling

Loading State

Always use Skeleton — never plain text:

// ❌ Banned
if (isLoading) return <div>Loading...</div>
if (isLoading) return <div style={{ padding: '1rem' }}>Loading items...</div>

// ✅ Correct — table load
if (isLoading) {
  return (
    <div className="space-y-2">
      {Array.from({ length: 5 }).map((_, i) => (
        <Skeleton key={i} className="h-10 w-full" />
      ))}
    </div>
  )
}

// ✅ Correct — card grid load
if (isLoading) return <CardGridSkeleton count={4} />

Error State

// ❌ Banned
if (isError) return <div style={{ color: 'red' }}>Error: {msg}</div>

// ✅ Correct
if (isError) {
  return (
    <Card>
      <CardContent className="pt-6">
        <p className="text-sm text-destructive">
          {error instanceof Error ? error.message : 'Something went wrong'}
        </p>
      </CardContent>
    </Card>
  )
}

Empty State

// ❌ Banned
{items.length === 0 && <p style={{ textAlign: 'center', color: '#666' }}>No items.</p>}

// ✅ Correct
{items.length === 0 && (
  <EmptyState
    icon={<InboxIcon className="size-10 text-muted-foreground" />}
    title="No items yet"
    description="Create your first item to get started."
    action={<Button onClick={onCreate}>Create Item</Button>}
  />
)}

12.5 Table Pattern

All data tables are wrapped in Card > CardContent className="p-0":

<Card>
  <CardHeader>
    <CardTitle>Items</CardTitle>
  </CardHeader>
  <CardContent className="p-0">
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>ID</TableHead>
          <TableHead>Name</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {items.map(item => (
          <TableRow
            key={item.id}
            className="cursor-pointer hover:bg-muted/50"
            onClick={() => navigate(item.id)}
          >
            <TableCell>
              <code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
                {item.id}
              </code>
            </TableCell>
            <TableCell>{item.name}</TableCell>
            <TableCell>
              <Badge variant={statusVariant(item.status)}>{item.status}</Badge>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  </CardContent>
</Card>

ID rendering — always use a <code> element with monospace styling:

<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{item.id}</code>

12.6 Status Badge Variants

Use a consistent variant mapping for Badge across all pages:

function statusVariant(
  status: string,
): "default" | "destructive" | "secondary" | "outline" {
  switch (status) {
    case "active":
    case "approved":
    case "completed":
      return "default"; // theme primary (green-ish)
    case "suspended":
    case "rejected":
    case "failed":
    case "deactivated":
      return "destructive"; // red
    case "pending":
    case "inactive":
    case "draft":
      return "secondary"; // neutral grey
    default:
      return "outline"; // plan tiers, misc categories
  }
}

Plan / tier badges always use variant="outline":

<Badge variant="outline">{platform.tier}</Badge>  // e.g. "starter", "growth"

12.7 Detail Page — Info Grid

Use <dl> with CSS grid for structured metadata (avoid raw key-value <div> stacks):

<Card>
  <CardContent className="pt-6">
    <dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
      <div>
        <dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
          ID
        </dt>
        <dd className="mt-1">
          <code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
            {item.id}
          </code>
        </dd>
      </div>
      <div>
        <dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
          Status
        </dt>
        <dd className="mt-1">
          <Badge variant={statusVariant(item.status)}>{item.status}</Badge>
        </dd>
      </div>
      <div>
        <dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
          Created
        </dt>
        <dd className="mt-1 text-sm font-medium">
          {new Date(item.createdAt).toLocaleDateString()}
        </dd>
      </div>
    </dl>
  </CardContent>
</Card>

12.8 Dialogs

Never build custom fixed-position overlay modals. Use Dialog from @neutrino-io/ui-core:

// ❌ Banned
<div style={{ position: 'fixed', top: 0, left: 0, zIndex: 1000, backgroundColor: 'rgba(0,0,0,0.5)' }}>

// ✅ Correct
<Dialog open={open} onOpenChange={onOpenChange}>
  <DialogContent className="sm:max-w-[480px]">
    <DialogHeader>
      <DialogTitle>Create Item</DialogTitle>
      <DialogDescription>Fill in the details below.</DialogDescription>
    </DialogHeader>

    <div className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="name">Name</Label>
        <Input id="name" value={name} onChange={e => setName(e.target.value)} required />
      </div>
      <div className="space-y-2">
        <Label htmlFor="type">Type</Label>
        <Select value={type} onValueChange={setType}>
          <SelectTrigger id="type"><SelectValue /></SelectTrigger>
          <SelectContent>
            <SelectItem value="a">Option A</SelectItem>
            <SelectItem value="b">Option B</SelectItem>
          </SelectContent>
        </Select>
      </div>
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>

    <DialogFooter>
      <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
      <Button type="submit" disabled={isLoading}>
        {isLoading ? 'Saving...' : 'Save'}
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

For pre-built confirmations, use ConfirmDialog directly:

import { ConfirmDialog } from '@neutrino-io/ui-core'

<ConfirmDialog
  open={confirmOpen}
  onOpenChange={setConfirmOpen}
  title="Confirm Action"
  description="This action cannot be undone."
  confirmText="Confirm"
  destructive
  onConfirm={handleConfirm}
/>

12.9 Destructive Actions — No window.confirm()

window.confirm() is banned. Use AlertDialog with a typed confirmation input for irreversible operations:

// ❌ Banned
if (window.confirm(`Delete ${item.name}?`)) { handleDelete() }

// ✅ Correct
const [deleteOpen, setDeleteOpen] = useState(false)
const [typedId, setTypedId] = useState('')

<AlertDialog open={deleteOpen} onOpenChange={open => { setDeleteOpen(open); setTypedId('') }}>
  <AlertDialogTrigger asChild>
    <Button variant="destructive">Delete</Button>
  </AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Delete {item.name}?</AlertDialogTitle>
      <AlertDialogDescription>
        This cannot be undone. Type <strong>{item.id}</strong> to confirm.
      </AlertDialogDescription>
    </AlertDialogHeader>
    <Input
      value={typedId}
      onChange={e => setTypedId(e.target.value)}
      placeholder={item.id}
    />
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction
        disabled={typedId !== item.id}
        className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
        onClick={handleDelete}
      >
        Delete
      </AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

12.10 Reference: @neutrino-io/ui-core Exports

Full component catalogue available from @neutrino-io/ui-core:

shadcn/ui components: AlertDialog, Avatar, Badge, Button, Calendar, Card, Checkbox, Collapsible, Command, Dialog, DropdownMenu, Form, Input, InputOtp, Label, Pagination, Popover, Progress, RadioGroup, ScrollArea, Select, Separator, Sheet, Sidebar, Skeleton, Sonner, Switch, Table, Tabs, Textarea, Tooltip

Layout primitives: Header, Main, TopNav

Navigation: NavGroup, CommandPalette, NavigationProgress, SearchTrigger, UserDropdown

Forms: PasswordInput, SelectDropdown, ConfirmDialog

Feedback: Alert, FeatureLoading, ComingSoon, LongText, PageSkeleton, CardGridSkeleton, EmptyState

Accessibility: SkipToMain

Theme: ThemeSwitch


12.11 Checklist — Pre-Commit Feature UI Review

Before committing any feature page or component, verify:

  • MUST: All components imported from @neutrino-io/ui-core — no raw HTML elements for interactive controls
  • MUST: Zero style=\{\{...\}\} props — only Tailwind class strings
  • MUST: Zero hardcoded hex/rgb/hsl values — only semantic tokens (text-muted-foreground, bg-muted, etc.)
  • MUST: Every page starts with <PageHeader>
  • MUST: Loading state uses Skeleton, not plain text
  • MUST: Error state uses Card + text-destructive
  • MUST: Empty state uses EmptyState component
  • MUST: Tables wrapped in Card > CardContent className="p-0"
  • MUST: IDs rendered as <code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
  • MUST: Status badges use the standard variant mapping
  • MUST: Dialogs use Dialog (not fixed-position divs)
  • MUST: Destructive actions use AlertDialog (not window.confirm())
  • MUST: Dark mode tested — no light-only hardcoded colors

Section 12 added 2026-03-01. Reflects patterns established in feature package redesign work. Related: NNO CLI · System Architecture · Shell Feature Config · Stacks

On this page

Overview1. Core Types1.1 FeatureDefinition1.2 FeatureRoute1.3 FeatureNavItemSub-Menu Navigation with Children1.4 ShellContext1.5 FeatureManifest (Build-Time Discovery)FeatureResourceRequirements1.6 Stack TypesStackFeatureRefStackResourceRequirementsStackDefinitionStackManifestStackInstance2. SDK Hooks2.1 useShell()2.2 useFeaturePermission(permission)2.3a useHasPermission(permission) — settings/auth context2.3 useFeatureEnv()3. Permission Model3.1 Declaration3.2 Permission Hierarchy3.3 Shell Gate Behaviour4. Provider Injection5. nno.config.ts — Feature Manifest File6. Scaffold Template (nno init)Generated src/feature.ts7. Testing Utilities8. Contract Validation (compile-time + runtime)9. Package Structure (packages/sdk)9a. Utility Exports (@neutrino-io/sdk/utils)10. Type Boundary: FeatureDefinition vs FeatureConfigThe Two-Type ModelFeatureDefinition — The Feature's ContractFeatureConfig — The Shell's Orchestration WrapperComposition in features.config.tsImpact on Feature Package AuthorsMigration from FeatureConfig Omit Pattern11. Versioning & Compatibility13. Stack Integration13.1 Stack Context in ShellContext13.2 Detecting Stack Context in the UI (Planned)13.3 Detecting Stack Context in a Feature Worker13.4 Resource Binding Names13.5 Per-Feature Config Overrides via StackFeatureRef13.6 Stack-Awareness Declaration12. UI Component Standards12.1 Component Source12.2 Styling — No Inline Styles12.3 Page Layout Anatomy12.4 State HandlingLoading StateError StateEmpty State12.5 Table Pattern12.6 Status Badge Variants12.7 Detail Page — Info Grid12.8 Dialogs12.9 Destructive Actions — No window.confirm()12.10 Reference: @neutrino-io/ui-core Exports12.11 Checklist — Pre-Commit Feature UI Review