AsheeUI ships for React Native as well as the web. The native package is
`@asheeui/native`, styled with NativeWind, and it shares the framework's component
vocabulary, prop contracts and design language with the web implementation without
sharing its source.

The rule behind the whole platform story is simple: **the contract is shared, the
implementation is the platform's.** A native button is a native button, not a web
button with the browser removed.

## What is shared, and what is not

| Layer | Lives in | Shared? |
| --- | --- | --- |
| Component names and prop contracts | `@asheeui/core` | Yes |
| Design language (spacing, radius, typography, colour roles, elevation, breakpoints, touch target) | `@asheeui/core` | Yes |
| The four-tier configuration cascade | `@asheeui/core` | Yes |
| Compatibility matrix (what each component means on each platform) | `@asheeui/core` | Yes |
| Component implementation | `@asheeui/web` (web), `@asheeui/native` | No, by design |
| Styling technology | Tailwind classes (web), NativeWind classes (native) | No |

Sharing the source would mean one of the two platforms gets the other's behaviour.
Sharing the contract means a developer learns one component list and one set of prop
names, and each platform behaves the way its users expect.

## Status

The native package has a component set, and it is now the whole matrix: every one
of the 58 components the compatibility matrix promises for native is implemented
and tested with the platform's own tooling, and `scripts/native-parity.json` holds
no queued module.

| Area | Delivered |
| --- | --- |
| Layout | `Container`, `Stack`, `HStack`, `VStack`, `Grid`, `Section`, `Centered`, `Split`, `Page` with `PageHeader`, `PageContent` and `PageFooter` |
| Components | `Button`, `Text`, `Card`, `Badge`, `Input`, `Chip`, `Avatar`, `Image`, `Clipboard`, `Marquee`, `Spinner`, `Skeleton` |
| The field family | `Textarea`, `SearchInput`, `PinInput`, `Switch`, `Radio` and `RadioGroup`, `Dropdown`, `MultiSelect`, `Autocomplete`, `Form`, `Stepper`, `FileUpload`, `Calendar` |
| Feedback and state | `Alert`, `EmptyState`, `ErrorState`, `LoadingState`, and the `ToastProvider` with `useToast` |
| Navigation and disclosure | `Link`, `Tabs`, `Accordion`, `Carousel`, `Pagination` |
| The page shell | `Navbar` with its `TabBar`, `Sidebar`, `SidebarLayout`, `DocsLayout`, `AuthLayout` |
| A marketing page | `MarketingLayout`, `Hero`, `FeatureGrid`, `CTA`, `Footer`, `Testimonials`, `PricingCard` |
| Data display | `Table` and `DataTable`, both shipped as `RowList` |
| Overlays | `Modal`, `Drawer`, `Tooltip` |
| Responsive | `useBreakpoint`, and the pure `resolveBreakpoint` and `resolveGridColumns` |
| Configuration | Every component registers its defaults and resolves through the shared cascade |
| A composed page | `@asheeui/puck` renders a page the web editor composed, with the components above |

The field family arrived as one increment rather than one component at a time, because
its members are the same shape: they read the family's vocabulary from `@asheeui/core`
(`FieldConfig`, the field class dictionaries and the two rules the family states once),
they draw the same label block through `FieldShell`, and they resolve the same cascade.
What each one adds is its own control and its own configuration section.

Two of the family's members read the platform rather than a preference, and both are
recorded in the matrix as shared API rather than hidden:

- A code field types into **one** field behind its boxes, because a native control has a
  single caret; the boxes draw what the field holds. The rules are unchanged: `PinInput`
  reads `sanitizePinValue` from `@asheeui/core`, so a web field and a native field filter
  the same input the same way.
- An autocomplete shows its suggestions **under** the field rather than floating them
  beside it, because a reader comparing what they typed with what it matches has to see
  both at once.

`Calendar` closes the family. It collects a date, a time or both, and what the platform
forces is drawn and recorded rather than quietly ignored: the field opens its calendar on
**one surface at a time**, because the platform has no floating layer for a panel beside a
control, and the time is **stepped** through columns rather than picked from a platform
clock, because a platform time picker takes a colour *value* while the contract names a
colour *role*, and a control that cannot resolve the role would accept the prop and ignore
it. What is not the platform's is the framework's, and it is read from `@asheeui/core`: the
month grid, the written form of a chosen date, the two rules that decide which cells a
reader may reach (`isFutureDay` and `canGoToNextMonth`), and the vocabulary that says what
an empty field collects. A day the web refuses is a day native refuses.

The feedback and state family arrived next, as one increment, for the same reason the field
family did: an `Alert`, an `EmptyState`, an `ErrorState` and a `LoadingState` are four
presentations of one question, what is this region telling the reader, and the
`ToastProvider` is the same question raised above the screen. Three things are shared
rather than restated:

- **How a message is announced.** The framework names two levels, and the platform has no
  `status` role, so `@asheeui/core` states the relation between the two vocabularies once
  (`NATIVE_ANNOUNCEMENT_LIVE_REGION` and `resolveNativeAnnouncementRole`), and an alert, a
  state and a toast all announce themselves through it. A failure interrupts on the
  platform because it interrupts on the web.
- **What a message looks like.** The treatments and colour roles a message surface can wear
  are one pair of maps (`NATIVE_MESSAGE_SURFACE_CLASS`, `NATIVE_MESSAGE_TEXT_CLASS`), read
  by the alert and by the toast, so retuning one retunes the other.
- **How long a queue waits.** The toast's default timeout and its queue depth are stated
  in `@asheeui/core` (`TOAST_FALLBACK_TIMEOUT_MS`, `TOAST_FALLBACK_MAX_TOASTS`), because
  they are decisions about a reader's attention rather than about a platform.

What the platform forces is drawn and recorded rather than quietly ignored. A decoration
is a character rather than a drawing, because this package ships no icon set and its other
components already draw their affordances from text. The toast stacks its messages in a
layer of its own and anchors them to the **bottom centre** by default, because the
platform's own snackbar cannot carry the title, the action or the dismissal the contract
names, the same argument that keeps the native spinner a themed ring rather than an
activity indicator. An `EmptyState` has no `as` prop, because the platform has one
container. And the technical detail of an `ErrorState` is opened from the component's own
state, because the platform has no `details` element and the detail is not application
state.

The rest is the framework's, read from the same place the web reads it: `resolveCascade`
for every option, `NATIVE_RADIUS_CLASS` for corners, `SPACE_MIN_HEIGHT_CLASS` for the room a
loading region claims, and one helper (`openDestination`) for the destination a configured
action describes.

The navigation and disclosure family followed, and it is the family where the platform's own
behaviour does most of the work. What is shared is the vocabulary and the scale: a link's
emphasis, underline and density, a tab's items and its active and inactive treatment, an
accordion's items and its treatments, a carousel's slides and its timing. What the platform
does with them is read from the platform rather than imitated:

- A link underlines itself while it is **pressed**, because there is no pointer to hover with,
  and it follows its destination through the platform's URL handler unless the consumer handles
  the press itself or names the component that should render the link, the same substitution
  API the web link offers.
- A tab bar **scrolls with the platform's scroll view** rather than with a wrapped overflow,
  and the bar is not an accessibility element of its own: a container that is one hides the
  controls inside it from a reader, so a native tab bar is a row of tabs, each carrying its
  role and whether it is selected.
- An accordion opens with the **platform's own layout animation** rather than with a measured
  height, and a closed panel is not drawn at all, which is what takes it out of the
  accessibility tree, the platform's answer to the web's `inert`.
- A carousel **pages** with the platform's scroll view, so a slide arrives where the screen is.
  That is also why `loop` wraps only the autoplay here: a scroll view that teleported its
  content back to the start would be lying about where the reader is. `pauseOnHover` becomes
  "pause while a thumb is on it", which is the same intent on a platform with no pointer.

Two things the family states that the web states differently on purpose: a treatment a
component has none for, a global default stated for a filled control, resolves to the
treatment the component documents, and a decoration is a character rather than a drawing,
because this package ships no icon set.

The overlays came last, and they are the family where the platform's own arrangements replace the
framework's. What is shared is what an overlay is: a modal's widths and positions, a drawer's
sizes and placements, a hint's treatments, colour roles, sizes, placements, arrow, offset and
delay, all of it read from `@asheeui/core`, so a consumer configures them once for both.

What is not shared is the panel itself, and the platform's answer is better than a copy of the
web's would be:

- A modal is the platform's **own modal presentation**, which takes the screen above everything
  else, keeps the reader inside it, and hands the framework the platform's way out, the back
  gesture or button, through the platform's own callback. That is what `closeOnEscape` names
  here. The surface takes its own presses, so pressing what a dialog holds never dismisses it.
- A drawer is a **sheet** rather than a panel at the side of the page, because a panel at the
  side of a phone has nowhere to be. A top or bottom placement is already the edge a sheet comes
  from and is kept; a side placement resolves to the sheet, which the shared class dictionaries
  state. The movement is the platform's slide, from the edge it was given.
- A tooltip is shown on a **long press**, because there is no pointer to rest on the trigger, and
  the same `delay` the web counts is the length of the platform's long press. Both halves of that
  delay work on the device: the hint stays for the `close` half after the press ends, a number
  states both halves at once, and an object with no `close` hides the hint the moment the thumb
  lifts. It is placed against the platform's own measurement of its trigger, and the placement
  reads exactly as the web's does, a side placement resolving to the vertical direction it reads
  from, because a hint beside a control has nowhere to be on a screen a thumb is already holding.
  Because a control may claim the press for itself, the hint is also stated as the trigger's
  **accessibility hint**, so a reader using assistive technology is told it without having to find
  the gesture, but that statement is **text only**: the platform's hint cannot hold a node, so it
  is made only when `content` is a string, and node content is read once the hint itself is open.

Two of the overlays name options the platform leaves out, and the modules say so: a portal and a
layer order are about where something sits in a document, and the platform has no document.

The shell came last, and it is the increment that closes the matrix. A `Navbar`, a `Sidebar` and
the three compositions that arrange them are the components a whole screen is built out of, so
what the platform decides about them is about a screen rather than about a document:

- A bar **scrolls its row of destinations** rather than opening a disclosure, because a browser's
  bar is a single line it cannot scroll sideways and a platform's is not. The disclosure's three
  options, `mobileOpen`, `onMobileOpenChange` and `mobileLabel`, resolve through the shared
  contract and change nothing, and where the web marks the current destination with `aria-current`
  the platform states the same fact as the control's selected state. The navbar module also
  publishes `TabBar`, the platform's own second half of a navigation: the same destinations and
  the same states, at the edge a thumb reaches, which the web needs no equivalent for because its
  links are already in the bar.
- A sidebar **collapses to a rail** exactly as it does on the web, and a collapsed row explains
  itself through the framework's native `Tooltip`, which the platform shows on a long press rather
  than while a pointer rests on it. A row is the framework's own `Link`, so a consumer keeps its
  routing. The width transition and the pointer cursor the web declares resolve through the shared
  contract and state nothing, because a class cannot promise movement or a cursor the platform
  does not have.
- `SidebarLayout` and `DocsLayout` are **stacked screens**. The shell asks `useBreakpoint` once
  and states the direction it means, stacked on a phone, and beside the content from `lg`, with
  the column placed outside the consumer's scrolling region, which is the platform's own way of
  keeping it in view, so `stickySidebar`, `stickyNavigation` and the contents' `stickyToc` resolve
  and change nothing.
  A documentation page's three regions become three stacked sections in the order the web's own
  flow gives them at its narrow widths, each headed with the name the web gives its landmark,
  because a platform screen has no landmark vocabulary but does let a region be titled.
- `AuthLayout` is **what fills the height the platform gives the screen**: the form is centred in
  it, the media column is decided once by `useBreakpoint`, and nothing scrolls, because the
  keyboard and the reading order of a platform screen are the screen's own business.

The queue in `scripts/native-parity.json` is empty as a result: every component the
matrix classifies for native is implemented, so platform support is a stated fact
rather than something a developer discovers by trying, and nothing is left for a
later increment.

`@asheeui/native` is published, on the same version line as the web renderer: `@asheeui/native` 2.5.0
and `@asheeui/web` 2.5.0 are the same release, built against `@asheeui/core` 2.3.1. An application
installs it directly, or reaches it through the `asheeui` umbrella, which routes an import to this
package on a React Native platform. The matrix and the parity check above are the record of what it
provides: every component the matrix classifies for native is in the release, and nothing is left
queued.

The version line is each package's own rather than a promise that the two renderers always move
together. They share 2.5.0 because this release changed both; a later release of one is not a release
of the other, and the release table keeps the current version of each.

One rule keeps a consumer's install resolvable: a published package may depend only on published
packages. `@asheeui/web` depends on `@asheeui/core` and on `@floating-ui/react`, `clsx` and
`tailwind-merge`, and `@asheeui/native` depends on `@asheeui/core` and on its peers, which is checked
by reading the manifest of the released tarball rather than the one in the repository.

## Platform support, per component

The compatibility matrix is data, and every public component has exactly one
classification:

| Classification | Meaning | Examples |
| --- | --- | --- |
| Shared | One contract, and the platform implementation is the platform's | `Button`, `Text`, `Card`, `Badge`, `Input`, `Chip`, `Spinner`, `Skeleton`, `PinInput`, `Form`, `Radio`, `Switch`, `Stack`, `Section`, `Grid`, `EmptyState`, `Alert`, `LoadingState`, `ErrorState`, `SearchInput`, `Stepper` |
| Shared API, separate implementation | The concept is shared and the platform behaviour differs, deliberately | `Modal` (platform modal), `Drawer` (bottom sheet), `Tooltip` (long press), `Tabs`, `Table` (rows, not a table), `DataTable` (a list), `Centered` (centres on the axis and claims the height, but has no contained width, the platform's own screen width is the measure), `Page` (screen with safe areas), `Pagination` (load more), `Split` (the same two panes, decided from the window rather than by a media query), `FileUpload` (the document picker), `Calendar` (one surface at a time, and the time is stepped rather than typed), `SidebarLayout` and `DocsLayout` (a stacked screen), `Navbar` (a header with a tab bar) and `Sidebar` (the same list, collapsing to a rail) |
| Web only | The concept has no meaningful native equivalent | `Breadcrumb` (native uses a titled header), `ResizableScreen` (a desktop idiom) |
| Not applicable | The component compensates for a browser constraint the platform solves itself | `Keyboard` (the platform has one) |

The matrix is enforced by a test, so a component cannot be added to the framework
without a platform decision being recorded for it.

## Keeping the components in step

A stored classification is a promise, and a promise that nothing checks decays. So
the matrix is also read by a guard:

```bash
pnpm check:native-parity          # verify, exit non-zero on drift
pnpm check:native-parity --list   # print the whole expectation table
```

The guard reads the matrix from its source, the moment an entry is written it is
seen, and it asks what `@asheeui/native` *publishes* rather than what its file names
suggest, by following the package's `export *` statements from its entry point. It
then fails in three cases:

- A component the matrix promises for native is neither exported nor queued.
- A component is exported but still sits in the queue.
- The queue names a module the matrix does not promise for native.

The queue itself is `scripts/native-parity.json`. It has two halves: `overrides`,
which names the native component where native publishes a different name from the
matrix's module (`typography` ships as `Text`, `table` and `data-table` ship as
`RowList`, `toast` ships as `ToastProvider`), and `pending`, which is what is left to
implement. The queue is a ratchet and may only shrink: implementing a queued component
fails the check until its entry is removed, so it cannot quietly become a list of what
nobody got round to. It is empty now, and it stays in the file because the next
component added to the matrix either ships for both platforms or is recorded there. CI
runs the guard, so a component cannot ship for one platform alone by accident.

## The layout kit and responsiveness

The layout primitives exist on native with the same props and the same configuration
cascade as the web, except where the table below records a difference:

| Primitive | What it decides natively |
| --- | --- |
| `Container` | A maximum width and a gutter, for a tablet or a desktop-sized window |
| `Section` | Vertical rhythm and a background band |
| `Stack`, `HStack`, `VStack` | One axis, a token gap, alignment, distribution and wrapping |
| `Grid` | A column count per breakpoint, resolved from the window |
| `Centered` | One block in the middle, on the axis you choose, the window's own width is the measure, so there is no `contained` width to state |

Responsiveness is a value rather than a class prefix, because the platform reports
the window rather than matching a media query:

```tsx
const { isAtLeast } = useBreakpoint();

<Stack direction={isAtLeast("md") ? "row" : "column"} gap="lg">
  <Card title="Inbox" description="12 unread" />
  <Card title="Sent" description="348 this month" />
</Stack>;
```

`useBreakpoint()` is exported under the same name, with the same shape, by `@asheeui/web` — the
platform's hook reads its window, the web's measures the window after mount — so a component that
has to choose between two arrangements asks once and runs on both. The rule itself
(`resolveBreakpoint`) and the state's interface (`BreakpointState`) live in `@asheeui/core`, so
neither renderer owns the boundaries.

`Grid` uses the same vocabulary for its column count:

```tsx
<Grid columns={1} columnsMd={2} gap="lg">
  <Card title="Inbox" description="12 unread" />
  <Card title="Sent" description="348 this month" />
</Grid>;
```

Two rules keep this honest. The breakpoints are the shared ones, so `md` means
768 density-independent pixels on both platforms. And the inheritance rule is a
pure function (`resolveGridColumns`), tested at every breakpoint, so a count
stated at a smaller breakpoint carries to the larger ones exactly as it does on the
web.

The kit avoids responsive class prefixes on purpose. A prefix would depend on
NativeWind's breakpoints happening to agree with the framework's, and the
framework states its breakpoints as data, so a component reads them instead.


## Configuration

The native provider resolves configuration through the same cascade as the web
implementation: instance prop, then the component's configuration, then the platform
default, then the value the component documents. It also accepts the same **palette and
theme** the web renderer does, and paints them: `color` holds the palettes per theme name,
`defaultTheme` chooses one, and `"system"` follows the device's colour scheme.

```tsx
import { AsheeUIProvider, Button } from "@asheeui/native";

export function App() {
  return (
    <AsheeUIProvider
      config={{
        color: {
          light: { primary: "#047857", background: "#FFFFFF" },
          dark: { primary: "#34D399", background: "#0A1410" },
        },
        defaultTheme: "system",
        defaultVariant: "bordered",
        defaultRadius: "sm",
        components: { button: { size: "lg", fullWidth: true } },
      }}>
      <Button onPress={save}>Save invoice</Button>
    </AsheeUIProvider>
  );
}
```

`AsheeUIProvider` is the provider under the name the umbrella's web branch uses;
`AsheeNativeProvider` is the same component. The device accepts the web renderer's own
`ExternalConfig` — plus the platform-only `defaultSize` — so a configuration object typed that
way describes both, and `AsheeConfig` is the same type under the name `asheeui` resolves per
platform. The component sections the shared configuration carries are one type; the sections
the web package declares in its own files (`card`, `typography`, `container`, the field family
and the layout ones) are added by the device's package, so a native-only installation can
configure them and a web-typed object is still accepted.

Rendering a framework component outside the provider throws rather than defaulting
silently, because a missing provider is a setup mistake and a silent default would
hide it. The provider also claims the height it is given, which is why it belongs at the
root of the tree.

## Setup

The native renderer supplies components, not a platform. Everything below adapts the
application's own configuration rather than replacing it, and the steps are in the order the
pipeline runs: Babel turns JSX into elements, Metro hands NativeWind the stylesheet, Tailwind
compiles the classes it can read, and the provider supplies the configuration.

### 1. Install the peer dependencies

```bash
npx expo install nativewind react-native-reanimated react-native-safe-area-context
npm install @asheeui/native
```

`react-native-reanimated` is what NativeWind's press and layout transitions ask for, and
`react-native-safe-area-context` is what the page shell measures its safe areas with. Install
the React Native copy the application already has rather than a second one.

**Do not install `react-native-css-interop` yourself.** NativeWind 4.2.7 brings its own copy,
and that copy is the half that turns a compiled class into a style. A pin of your own — a
leftover `0.2.1`, a tutorial's instructions, a lockfile from another project — resolves
*instead of* the copy NativeWind brings. The Tailwind compile still succeeds, the bundle still
contains every class name, and every component renders unstyled with no warning anywhere. If a
dependency of the application's pins it, match the version NativeWind ships.

### 2. Babel

```js
// babel.config.js
module.exports = function (api) {
  api.cache(true);

  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};
```

Two presets, and the order matters. `babel-preset-expo` is the platform's own — the JSX
transform, the new architecture, the `react-native` resolver — and NativeWind's preset comes
*after* it, because the CSS transform runs on the output of the JSX transform rather than
instead of it. NativeWind's preset is the half that points JSX at the interop runtime (it
installs `@babel/plugin-transform-react-jsx` with `importSource: "react-native-css-interop"`, and
rewrites `React.createElement` the same way), and `jsxImportSource: "nativewind"` states the same
thing to the platform's preset, which is why NativeWind's documentation asks for both. It also
adds the Reanimated plugin, so an application does not have to list it again.

The package's own compiled modules emit through that runtime themselves, so the framework's
components are styled even without this configuration; the application's own JSX would not be.

### 3. Metro, and the stylesheet it compiles

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

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

```css
/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
```

```tsx
// App.tsx
import "./global.css";
```

The stylesheet has to be both configured **and imported**. One that is named in
`metro.config.js` and never imported is a bundle with no styles in it, which is the first
thing to check when nothing is styled.

### 4. Tailwind: the package's preset

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

module.exports = {
  presets: [asheeui],
  // The application's own files, then the framework's — those are already absolute.
  content: ["./src/**/*.{ts,tsx}", ...asheeui.content],
};
```

The preset is three things in one:

- **NativeWind's preset**, so the configuration compiles for the platform rather than for a
  stylesheet.
- **The eight colour roles** the components reference by name — `bg-background`,
  `text-foreground`, `bg-primary`, `border-border`, `bg-secondary/40` — each stated as the
  custom property it reads, `rgb(var(--ashee-<role>) / <alpha-value>)`. The values are the
  application's and come from the configuration: the provider resolves `color` and
  `defaultTheme` and paints the result, so the palette is stated **once** for both renderers
  rather than copied into this file.
- **The package's own compiled modules in `content`.** Tailwind compiles only a class it can
  read in a file it is allowed to scan, and the framework's class strings are in the package's
  `dist`, outside the application's directory. `asheeui.content` is an **absolute** glob
  because the installed location is not `node_modules/@asheeui/native`: pnpm, Bun's isolated
  installs and npm with a different peer set each place it somewhere else, and how many
  directories up that is depends on how deeply the application itself is nested.

The spread is not decoration. Tailwind v3 does not merge a preset's `content` into a
configuration that states its own — the application's list replaces it — so the framework's
glob has to reach the application's array. A `content` array that names only the
application's own files compiles none of the framework's classes, and the screen is half
styled rather than unstyled, which is harder to spot.

**Do not state the roles here as colours.** An application that writes
`theme.extend.colors: { primary: "#047857" }` overrides the preset's expression, which fixes
that role at build time and stops it following the theme — so `defaultTheme` and `color` would
paint every role except that one. A build-time palette is a legitimate choice; it just is not
the configuration's palette, and the two do not mix.

A role in the configuration must be a hex colour or an `rgb()`/`rgba()` value. The platform
applies a theme as channels (`rgb(var(--ashee-primary) / <alpha-value>)`), so a value it cannot
read as channels is an error naming the role rather than a surface that paints nothing, and a
role is opaque: the alpha of an `rgba()` value is ignored, because the framework's own classes
carry their alpha (`bg-secondary/40`).

The same eight roles are what `@asheeui/web` resolves from its `--ashee-*` custom properties,
which is what makes one palette one design language rather than two copies that drift.

### 5. The provider

```tsx
// App.tsx
import { AsheeUIProvider } from "@asheeui/native";

export function App() {
  return (
    <AsheeUIProvider config={{ defaultTheme: "system" }}>
      <Screen />
    </AsheeUIProvider>
  );
}
```

Rendering a framework component outside the provider throws rather than defaulting
silently, because a missing provider is a setup mistake and a quiet default would hide it.
With no `color` the provider paints the framework's own light and dark palettes, so a screen
is themed before the application states anything.

### When nothing is styled

The framework's components carry no fallback `style`, deliberately: a fallback would hide a
broken pipeline behind a screen that looks merely plain. So the failure has one shape — the
right words, the right structure, no design at all: no page padding, no card surfaces, no
borders, no type scale, and the status bar sitting over the first heading. These are the
causes, in the order they are worth checking:

1. **The stylesheet is never imported.** `global.css` is named in `metro.config.js` and not
   imported by the application. Step 3.
2. **The framework's modules are not in `content`.** The application's `content` names its own
   files and not `asheeui.content`, so the framework's classes compile to nothing. Half the
   screen is then styled — the application's own components are — and the framework's are not.
3. **`react-native-css-interop` is pinned away from the version NativeWind ships.** The
   compile succeeds, the classes are in the bundle, and nothing is applied.
4. **A role is stated as a colour in `tailwind.config.js`.** The preset states each role as
   the custom property the provider paints; a hex in `theme.extend.colors` replaces that
   expression, so the role is fixed at build time and stops following `color` and
   `defaultTheme`. Step 4. (A role that is missing from the preset's list compiles to nothing,
   which looks the same: role classes do nothing while spacing and layout still work.)
5. **The package's compiled modules do not emit through NativeWind's runtime.** This was a
   defect of `@asheeui/native` 2.5.0 and older, and it is fixed: the artifact imported
   `react/jsx-runtime`, so NativeWind's Babel transform — which rewrites JSX and
   `React.createElement`, and a compiled module contains neither — never saw the framework's
   `className`. Upgrade the package. A consumer who cannot upgrade yet can restore the runtime
   with a Metro alias **scoped to the package**, so React's own runtime is untouched:

   ```js
   // metro.config.js — @asheeui/native 2.5.0 and older only
   const RUNTIMES = new Set(["react/jsx-runtime", "react/jsx-dev-runtime"]);
   const PACKAGE_DIST = /@asheeui[\\/]native[\\/]dist[\\/]/;
   const base = config.resolver.resolveRequest;

   config.resolver.resolveRequest = (context, moduleName, platform) => {
     const resolve = base ?? context.resolveRequest;

     return RUNTIMES.has(moduleName) &&
       PACKAGE_DIST.test(context.originModulePath ?? "")
       ? resolve(context, "nativewind/jsx-runtime", platform)
       : resolve(context, moduleName, platform);
   };
   ```

The provider checks the pipeline itself, so a development build does not have to be diagnosed
by hand: once it has mounted it compares the runtime its own components use against React's,
asks NativeWind whether the Babel transform, the stylesheet data and its preset are in place,
and warns once with the cause it found. The same diagnosis is available as data:

```ts
import { verifyNativeWind } from "@asheeui/native";

const report = verifyNativeWind();

if (!report.ok) {
  for (const check of report.checks.filter((check) => !check.ok)) {
    console.log(check.summary, check.remedy);
  }
}
```


## Development and testing

Each platform is tested with its own tooling, and neither suite pretends to be the
other:

| Platform | Runner | Why |
| --- | --- | --- |
| Web | Vitest and Testing Library, on a DOM | The web implementation's behaviour is DOM behaviour |
| Native | Jest with React Native's preset and the React Native Testing Library | The native implementation's behaviour is the platform's: roles, accessibility states, press interaction |

Two properties of the native test environment are worth knowing before writing a
native test:

- The React Native Testing Library is asynchronous: renders and events are awaited.
- Queries are about the platform's accessibility tree, so a component is found by its
  role and asserted through `accessibilityState` rather than through a DOM.

Run one platform's tests on its own:

```bash
pnpm --filter @asheeui/native test     # native
pnpm --filter @asheeui/web test             # web
pnpm --filter @asheeui/core test     # the shared layer
```

One check is not a test, because it reads build output rather than sources:

```bash
pnpm --filter @asheeui/native build
pnpm check:native-artifact
```

It asserts that no published module emits elements through `react/jsx-runtime`, and that at
least one emits through the interop runtime. The native suite cannot make that assertion: it
compiles this package's own sources, which contain JSX, so its transform wraps the JSX and the
components are styled in tests. The published artifact contains no JSX at all, which is why the
suite can be green while every consumer's screen is unstyled — that is exactly the defect the
check exists for, and CI runs it after the build.

## A composed page

A page a web editor composed is rendered on a device by `@asheeui/puck`. The package
holds one set of block specs, the label a builder shows, the fields it fills in and the
value each block starts as, and two drawings for each block, so a page cannot mean one
thing in the editor and another on a screen:

```tsx
import { PuckPage } from "@asheeui/puck";

export function PublishedPage({ page }) {
  return <PuckPage data={page} />;
}
```

What the platform renders is the same component set documented above, reached through the
same provider. Two things are deliberately absent. There is no editor: composing is a web
concern, and the platform is where a composed page is shown. And the package imports
nothing from `@puckeditor/core`, which is what lets a native bundle reach it at all, the
editor is an optional peer the web half alone needs.

One arrangement differs and is stated rather than hidden. A two-column block offers a
ratio on the web, where a stylesheet expresses one; the platform resolves its own window
and offers whole column counts, so the block draws the framework's two columns once there
is room for them.

## Styling rules on native

NativeWind compiles the classes it can read in the source, exactly as Tailwind does
on the web. Two consequences follow, and both are non-negotiable in this codebase:

1. Every class is a complete, static string in a styles module. A class assembled at
   runtime produces no styling at all.
2. Density is a design-language decision rather than a per-component one. A control
   is at least as tall as the shared touch target, and `@asheeui/core` states and
   tests that value.

Animation follows the same rule, and `Spinner` is the case that sets it: the shared
layer states the duration and the classes, and the platform states the mechanism.
NativeWind cannot register a component that animates itself, so motion here is React
Native's `Animated` driven on the native thread (`useNativeDriver: true`), the view
that moves carries nothing else, and the child inside it carries the classes, the
props and the accessibility. The ring is drawn from theme classes rather than from a
platform activity indicator, because a platform control takes a colour *value* while
the framework's contract names a colour *role*: a control that cannot resolve the role
would accept the prop and ignore it.

A third rule belongs to the build rather than to a component, and it is the one a
consumer sees before any other. NativeWind does not read `className` at runtime: its Babel
transform turns JSX into elements of its own runtime, so a `className` becomes a `style`.
A compiled module contains no JSX and no `React.createElement`, which means a consumer's
Babel has nothing to rewrite in it and the runtime it imports is the only thing that can
route it. The published artifact therefore emits through `nativewind/jsx-runtime`
(`jsxImportSource` in `tsconfig.build.json`), and `pnpm check:native-artifact` fails the
build if that is ever lost — because the failure it prevents is a correct application with
every framework component unstyled, and nothing thrown to say so.

The colour rule follows from the same reasoning. A class names a **role**, not a colour:
`bg-primary` compiles to `rgb(var(--ashee-primary) / <alpha-value>)`, and the provider paints
`--ashee-primary` from the configuration's palette, so the same class renders the
application's colour, and the same palette renders on both platforms. That is why no
component's class string may name a raw Tailwind colour, and why a consumer who states
`theme.extend.colors` trades the runtime palette for a build-time one.

## Accessibility on native

Accessibility is a property of each component rather than a later addition:

- Components carry the platform's roles (`accessibilityRole`).
- State is announced through `accessibilityState`, so a disabled or busy control is
  reported as disabled or busy.
- Decorative elements inside a control are hidden from assistive technology, so the
  control keeps one stable accessible name.
- Touch targets respect the shared minimum; a control that misses it is a defect
  rather than a style choice.

## What is deliberately not here

| Not ported | Why |
| --- | --- |
| Puck's *editor* | It is a web authoring concern, and a block editor for a device would be a different product |
| A framework-wide animation system | Motion belongs to the component that needs it, and a general layer would be speculation |
| The web's table, breadcrumb and resizable-split components | Their native equivalents are lists, a titled header and a platform split view |
| The web package's source | Sharing source would give one platform the other's behaviour, which is what this architecture exists to avoid |
