A shared screen is a component that imports `asheeui` and nothing platform-specific. It renders in a
browser and on a device without knowing which one it is on. This page is the set of rules that makes
that true, and the places where a difference is allowed.

The reference is the shared kit in the [monorepo blueprint](/guides/monorepo): seven screens, one
theme, two shells.

## The three rules

1. **Import the umbrella.** `asheeui` only. Never `@asheeui/web`, `@asheeui/native`, `react-dom` or
   `react-native` in a shared file. A renderer import is a compile error on the other platform, and
   that error is the boundary being useful.
2. **Use the framework's primitives.** No `div`, `span`, `p`, `button`, `View`, `Text` or `Pressable`.
   `Typography` is the text, the layout kit is the box, `Button` is the press, `Image` is the picture.
   A raw element is a style decision for one platform.
3. **State the contract, not the implementation.** A screen takes data and callbacks; the shell owns
   the router, the URL and the platform's navigation. `onOpenWorkout(id)` is portable;
   `router.navigate("/workouts/" + id)` is not.

## The screen's props

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

export function DashboardScreen({ onNavigate, onOpenWorkout }: ScreenProps) {
  return (
    <VStack gap="xl">
      <ScreenHeading eyebrow="Good morning" title="Today" onAction={() => onNavigate("workouts")} />
      <StatGrid />
      <Button onPress={() => onOpenWorkout("lower-body")}>Start lower body power</Button>
    </VStack>
  );
}
```

The shell maps the callbacks to its own router. The web shell uses `useNavigate`; the native shell uses
a state machine, Expo Router, or whatever the application already has. The screen never sees either.

## What is portable

The complete list is [the portable surface](/concepts/portable-surface). In practice:

| Need | Portable |
| --- | --- |
| Text | `Typography` (or `Text`, the same component) |
| Layout | `Page`, `Section`, `Container`, `Grid`, `Stack`, `HStack`, `VStack`, `Centered`, `Split` |
| A control | `Button`, `Link`, `Card`, `Badge`, `Chip`, `Avatar`, `Image` |
| A form | the field family, `Form`, `PasswordInput`, `PinInput`, `Stepper` |
| A list | `Table`, `DataTable` (the native branch draws them as rows) |
| Feedback | `Alert`, `ToastProvider` + `useToast`, `EmptyState`, `ErrorState`, `LoadingState`, `Skeleton`, `Spinner` |
| Disclosure | `Tabs`, `Accordion`, `Carousel`, `Modal`, `Drawer`, `Tooltip` |
| Theme | `useTheme`, `THEME_STORAGE_KEY`, `ThemeSelection` |
| Types | `Color`, `Radius`, `Size`, `Space`, `Variant`, `ThemeName`, `AsheeConfig` |

`RowList`, `TabBar`, `Breadcrumb`, `ResizableScreen` and the keyboard family are deliberately not on
that list. They belong to one renderer, and a shared screen that wants one is telling you the screen
belongs to that platform.

## Events

The portable event names are `onPress` and `onChange`:

```tsx
<Button onPress={save}>Save</Button>

<Input
  label="Email"
  value={email}
  onChange={(event) => setEmail(event.target.value)}
/>
```

`onClick` also compiles on the web and is accepted on a device (it is the web event name, not a second
handler). `onChangeText` is the raw native event; a shared screen should use `onChange` so the same body
runs on both.

## The differences a shared file may state

Some differences are the product's, not the platform's, and a shared screen can state them:

### The layout at a breakpoint

```tsx
import { Grid, useBreakpoint } from "asheeui";

const { isAtLeast } = useBreakpoint();

<Grid columns={1} columnsMd={isAtLeast("md") ? 2 : 1} gap="lg">…</Grid>;
```

The same code runs on both: the web's hook measures the window, the device's reads it. A responsive
class prefix (`md:grid-cols-2`) is a web-only decision and compiles to nothing on a device.

### A count that differs by platform

```tsx
import { Platform } from "react-native"; // only in a shell, never in the kit
```

If the kit needs a platform decision, it should take it as a prop:

```tsx
<WorkoutList columns={compact ? 1 : 3} />
```

The shell decides `compact`; the kit renders it. That keeps the platform import in the shell.

### A field's helper text

The native field family has the same label, description and message as the web. The one difference
worth stating in the kit is the search field: `SearchInput` has no `placeholder` on a device, so the
label and description carry the prompt.

```tsx
<SearchInput label="Search the library" description="Name, focus or coach" … />
```

## The escape hatch

When one screen genuinely needs a platform API, do not hide it in the kit. Split the screen:

```text
packages/kit/src/screens/studio.tsx          the page, the data, the layout
apps/web/src/screens/studio-editor.tsx       the Puck editor (web-only)
apps/mobile/src/screens/studio.tsx           the native renderer (already native)
```

The kit exports the common part; each application owns the platform part. The alternative — a
`Platform.OS` branch inside the kit — compiles, but it moves the platform into the shared file and the
next screen copies it.

The repository's studio is exactly this split: the shared `StudioScreen` renders a Puck page on both
platforms, and the editor exists only on the web.

## Theming without a stylesheet

A shared screen states colours as roles, not values:

```tsx
<Card className="bg-primary/10 border-primary/30">
  <Typography role="heading-sm">Push volume</Typography>
</Card>
```

`bg-primary` compiles to `rgb(var(--ashee-primary) / <alpha-value>)` on both platforms; the provider
paints the variable from the configuration. A hex inside the kit would be a colour that cannot follow
the theme and a divergence waiting to happen.

`className` is allowed; it is the shared subset of Tailwind that both compilers understand. What is not
allowed is the vocabulary only one compiler understands: `hover:`, `focus:`, the responsive prefixes,
`shadow-*`, `ring-*`, `z-*`, `transition-*`. The native linter fails on them.

## Testing a shared screen

Typecheck is the first test: the kit's `check-types` compiles it against both renderers (see
[the monorepo guide](/guides/monorepo#the-typecheck-that-keeps-it-portable)). A screen that passes both
is portable by construction.

The second test is the screen's behaviour on each platform's own runner:

| Platform | Runner | What it proves |
| --- | --- | --- |
| Web | Vitest + Testing Library on the DOM | the browser's roles, events and focus |
| Native | Jest + React Native Testing Library | the accessibility tree and the platform's press |

Do not try to run one test suite on both. The two platforms' queries are different languages, and a test
that mocks the platform away proves nothing about either.

## A checklist

- [ ] Imports only `asheeui` (and `@asheeui/puck` for a composed page).
- [ ] No DOM or React Native primitive.
- [ ] Navigation, URLs and the router are callbacks.
- [ ] Colours are roles, not hex values.
- [ ] `className` uses only the shared vocabulary.
- [ ] Responsive decisions use `useBreakpoint` or props, not prefixes.
- [ ] Typechecks with the web and the native conditions.
- [ ] The platform-specific part, if any, lives in each shell.
