This is the arrangement the framework is built for, and the arrangement the
[example applications](https://github.com/AsheeSoftworks/AsheeUI/tree/main/apps) use: **one shared kit**
of screens, a **web application** and a **native application**, and a shared configuration. The kit
imports `asheeui` and knows nothing about the platform; each application owns its shell, its router,
its stylesheet and its build.

The payoff is that a screen is written once. The cost is four pieces of wiring that nothing can remove:
the shared package, the two shells, the two styling pipelines, and a typecheck that reads the kit
through both renderers' types.

## The layout

```text
repo/
  apps/
    web/                  TanStack Start (or Vite + React)   asheeui + @asheeui/web
    mobile/               Expo + Metro + NativeWind          asheeui + @asheeui/native
  packages/
    kit/                  the shared screens, data and theme  asheeui only
  pnpm-workspace.yaml
  package.json
```

The kit is a package, not a folder reached by a relative path. That is what lets both applications
resolve it through the same specifier a published consumer would use, and what makes `tsc` check it
against the umbrella instead of against whichever application happens to import it.

## The three manifests

```jsonc
// packages/kit/package.json — the shared kit
{
  "name": "@acme/kit",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "exports": { ".": "./src/index.ts" },
  "dependencies": {
    "asheeui": "workspace:^"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}
```

```jsonc
// apps/web/package.json
{
  "dependencies": {
    "@acme/kit": "workspace:*",
    "asheeui": "workspace:^",
    "@asheeui/web": "workspace:^",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
```

```jsonc
// apps/mobile/package.json
{
  "dependencies": {
    "@acme/kit": "workspace:*",
    "asheeui": "workspace:^",
    "@asheeui/native": "workspace:^",
    "expo": "~58.0.0",
    "react": "^19.0.0",
    "react-native": "0.88.0",
    "nativewind": "^4.2.7"
  }
}
```

The renderer is a **peer** of the umbrella and a direct dependency of the application that chose it. The
kit must never depend on either renderer: a renderer in the kit's manifest is how a web-only import gets
found on a phone.

## The two shells

The screens are shared; the navigation is not. This is the platform difference worth designing for
first, because it changes the shell rather than one component.

### Web: a bar at the top

```tsx
// apps/web/src/shell.tsx
import { Navbar, Page, PageContent, PageFooter, type Config } from "asheeui";

const links = [
  { label: "Today", href: "/" },
  { label: "Workouts", href: "/workouts" },
  { label: "Nutrition", href: "/nutrition" },
];

export function Shell({ children }: { children: React.ReactNode }) {
  return (
    <Page>
      <Navbar brand="Pulse" links={links} />
      <PageContent>{children}</PageContent>
      <PageFooter>© Pulse</PageFooter>
    </Page>
  );
}
```

The router is the web's, so the bar should not hard-navigate. `Navbar` takes a `link` substitution:

```tsx
import { Link } from "@tanstack/react-router";
import { RouterLink } from "./router-link";

<Navbar
  brand="Pulse"
  links={links}
  link={{ component: RouterLink, props: {} }}
/>;
```

`RouterLink` renders the framework's `Link` with the same `href` and forwards the rest; the exact adapter
is in the [web guide](/platforms/web#routing).

### Native: a header and a bar at the bottom

```tsx
// apps/mobile/src/shell.tsx
import { Navbar, Page, PageContent, TabBar } from "asheeui";

const links = [
  { label: "Today", href: "/" },
  { label: "Workouts", href: "/workouts" },
  { label: "Nutrition", href: "/nutrition" },
];

export function Shell({ children }: { children: React.ReactNode }) {
  return (
    <Page>
      <Navbar brand="Pulse" links={links} />
      <PageContent>{children}</PageContent>
      <TabBar items={links} />
    </Page>
  );
}
```

Both `Navbar` and `TabBar` are on `asheeui` because the umbrella's native branch publishes them. The
shell is the one place the two applications differ; the screens below it are the same files.

## The shared kit

A shared screen takes its navigation as callbacks, because only the application knows its router:

```tsx
// packages/kit/src/screens/workouts.tsx
import { Button, Card, Grid, Typography } from "asheeui";

export interface ScreenProps {
  onOpenWorkout: (id: string) => void;
  onNavigate: (route: "today" | "settings") => void;
}

export function WorkoutsScreen({ onOpenWorkout, onNavigate }: ScreenProps) {
  return (
    <Grid columns={1} columnsMd={2} gap="lg">
      {workouts.map((workout) => (
        <Card
          key={workout.id}
          title={workout.title}
          description={workout.description}
          footer={
            <Button onPress={() => onOpenWorkout(workout.id)}>Start session</Button>
          }
        />
      ))}
    </Grid>
  );
}
```

The kit may use only the **portable surface**: components, hooks and types the umbrella publishes. The
rules that keep it portable are in [writing a shared screen](/guides/shared-components); the list is
[the portable surface](/concepts/portable-surface).

## The two styling pipelines

The web is Tailwind 4; NativeWind is Tailwind 3. They are separate pipelines on purpose, and the
umbrella spans both without hiding the split.

### Web

```css
/* apps/web/src/styles.css */
@import "tailwindcss";
@import "asheeui/styles";
@source "../../packages/kit/src/**/*.tsx";
```

`asheeui/styles` is the umbrella's one-line stylesheet: it imports the web renderer's tokens. The
`@source` line is not optional in a monorepo, because Tailwind 4 scans from the CSS file and the shared
package is outside the application's directory; without it, the kit's classes compile to nothing.

### Native

```js
// apps/mobile/tailwind.config.js
const asheeui = require("asheeui/tailwind-preset");

module.exports = {
  presets: [asheeui],
  content: [
    "./App.tsx",
    "./src/**/*.{ts,tsx}",
    "../../packages/kit/src/**/*.{ts,tsx}",
    ...asheeui.content,
  ],
};
```

The spread is required: Tailwind 3 replaces a preset's `content` with the application's own array, and
`asheeui.content` is the framework's own compiled classes, stated as an absolute glob because pnpm may
place the package anywhere. The full Expo setup is in [the Expo guide](/guides/expo).

## One configuration, two manifests

The kit exports the configuration object; both applications pass the same one to the provider.

```ts
// packages/kit/src/theme.ts
import type { AsheeConfig } from "asheeui";

export const config: AsheeConfig = {
  color: {
    light: { primary: "#2563EB", background: "#FFFFFF" },
    dark: { primary: "#60A5FA", background: "#0B1220" },
  },
  defaultTheme: "system",
  defaultRadius: "md",
  components: {
    button: { size: "md" },
    card: { variant: "elevated" },
  },
};
```

```tsx
// both apps
import { AsheeUIProvider } from "asheeui";
import { config } from "@acme/kit";

<AsheeUIProvider config={config}>{children}</AsheeUIProvider>;
```

`AsheeConfig` is the umbrella's type: on the web it is the web renderer's `ExternalConfig`, on a device
the native renderer's, plus the platform-only fields. One object typechecks on both.

## The typecheck that keeps it portable

A shared file can compile against the **web** types and still fail on a device. The kit needs both
checks. There are two ways to get them.

### Option A: two tsconfigs, two runs

```jsonc
// packages/kit/tsconfig.web.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "jsx": "react-jsx", "moduleResolution": "bundler" },
  "include": ["src"]
}
```

```jsonc
// packages/kit/tsconfig.native.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "customConditions": ["react-native"]
  },
  "include": ["src"]
}
```

```json
{
  "scripts": {
    "check-types": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.native.json --noEmit"
  }
}
```

The `react-native` condition is what makes `asheeui` resolve to `@asheeui/native`'s types. The same
condition is already in Expo's base tsconfig; state it explicitly in the second config so the check does
not depend on which application runs it.

### Option B: check from the applications

Each application's `tsc` reads the kit through its own install. That works, but it is slower and a
failure reports the application's path rather than the kit's. Option A is the one that gives a shared
package one command.

### The two type errors you will meet

| Error | Cause | Fix |
| --- | --- | --- |
| `process` is not defined in a library file | The kit's sources use a Node global; the web types allow it, the native config does not | Add `@types/node` to the kit and `"types": ["node"]` |
| `Cannot find module './global.css'` | A side-effect CSS import in a `.tsx` file | Add `declare module "*.css";` to the kit's ambient types, or import the CSS only in each app |

## Metro and the workspace

Expo's Metro does not follow pnpm's symlinks out of the box. Two settings matter:

```js
// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("node:path");

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");

const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, "node_modules"),
  path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.unstable_enablePackageExports = true;

module.exports = withNativeWind(config, { input: "./global.css" });
```

Do **not** set `disableHierarchicalLookup = true`. With pnpm, a package's own dependencies sit beside its
real path in the virtual store; hierarchical lookup is what finds them. Disabling it produces
`Unable to resolve "invariant"` and similar errors from inside React Native.

## What goes where

| Concern | Web app | Native app | Shared kit |
| --- | --- | --- | --- |
| Components and layouts | ✓ | ✓ | ✓ |
| Navigation chrome | `Navbar` at the top | `Navbar` + `TabBar` | destinations and callbacks |
| Router | TanStack Router / Next | Expo Router or state | none |
| Styling entry | `asheeui/styles` | `asheeui/tailwind-preset` + `global.css` | `className` only |
| Provider | root, above the router | root, inside `SafeAreaProvider` | the configuration object |
| Puck | editor + renderer | renderer only | the document type |
| Platform-only APIs | `@asheeui/web` | `@asheeui/native` | never |

## A page the two platforms disagree about

The studio is the clearest example. On the web it is editable: the editor stores a Puck document in
`localStorage` and the page renders it. On a device the same document is rendered by `@asheeui/puck`'s
native renderer, with no editor.

```tsx
// packages/kit/src/screens/studio.tsx
import { PuckPage, type AsheePuckData } from "@asheeui/puck";

export function StudioScreen({ data }: { data?: AsheePuckData }) {
  return <PuckPage data={data} />;
}
```

The editor screen is web-only; the renderer is shared. [Puck](/platforms/puck) has the full contract.

## Scripts and CI

```json
{
  "scripts": {
    "dev:web": "pnpm --filter web dev",
    "dev:mobile": "pnpm --filter mobile start",
    "check-types": "pnpm -r check-types",
    "build": "turbo run build",
    "test": "turbo run test"
  }
}
```

Two rules keep CI honest:

1. **The kit's typecheck is part of the root check.** A shared file that compiles on the web only is the
   defect this whole layout exists to prevent.
2. **Both applications build.** The web build catches a Tailwind `@source` mistake; the Expo export
   catches a Metro resolver or NativeWind mistake. Neither build catches the other's.

## The reference implementation

The repository's `apps/fitness-shared`, `apps/fitness-web` and `apps/fitness-mobile` are this blueprint
with seven screens. The interesting files are:

| File | What it shows |
| --- | --- |
| `apps/fitness-shared/src/theme.ts` | one `AsheeConfig`, destinations and screen props |
| `apps/fitness-shared/src/screens/*` | portable screens, no DOM and no React Native primitives |
| `apps/fitness-web/src/shell/AppShell.tsx` | top `Navbar`, router link substitution, footer |
| `apps/fitness-mobile/src/MobileShell.tsx` | header and bottom `TabBar`, safe areas |
| `apps/fitness-web/src/pages/StudioEditor.tsx` | the Puck editor, published to `localStorage` |
| `apps/fitness-shared/src/screens/Studio.tsx` | the shared Puck page both platforms render |
