Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/docs-voice/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ was rejected) and the "when **not** to use it" anti-sell — keep both.
`meta.ts` icons and tab icons stay. Nested brand groups (e.g. Svelte): icon
on the group only — don't repeat the same SVG on indented children.
- **Adapter list order:** core/vanilla → react → preact → solid → angular →
vue → lit → svelte (runes → store). Every multi-adapter listing.
vue → lit → alpine → svelte (runes → store). Every multi-adapter listing.

## Don't

Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/harden-pr/LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ By-design or false-positive findings — do not re-raise.

- **[public-api]** `packages/lit/src/index.ts` `layerClientContext` export — by-design; rationale on the const JSDoc.
- **[public-api]** `packages/lit/src/index.ts` `StackController` `queued`/`deferClient`/`bindClient` — by-design; rationale on the class/ctor JSDoc (factories are the supported path).
- **[correctness]** `packages/alpine/src/index.ts` multi-child `x-layer-outlet` template — by-design; Alpine `<template>` loops (`x-for`/`x-if`) require a single root element; outlet matches that contract (document in alpine.mdx).
- **[docs]** `apps/docs/content/adapters/index.mdx` Alpine footnote `⁷` vs `docs/architecture.md` `⁸` — by-design; each matrix numbers footnotes for its own footnote set (architecture also has Lit `⁷`).

## Deferred

Expand Down
5 changes: 5 additions & 0 deletions .changeset/alpine-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/alpine-layers": minor
---

Add `@stainless-code/alpine-layers` — Alpine plugin (`$layers`, `$layer`, `x-layer-outlet`, `Alpine.data('layerStack')`) plus Svelte-shaped JS hooks and CDN bootstrap.
7 changes: 7 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
"limit": "4.5 KB",
"gzip": true
},
{
"name": "@stainless-code/alpine-layers",
"path": "packages/alpine/dist/index.mjs",
"ignore": ["@stainless-code/layers", "alpinejs"],
"limit": "3 KB",
"gzip": true
},
{
"name": "@stainless-code/solid-layers",
"path": "packages/solid/dist/index.mjs",
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

Modals are just async functions you forgot to `await`.

Headless layer/stack manager — modals, dialogs, drawers, popovers, toasts are **layers in named stacks**. Open any layer from anywhere and `await` a typed result. A zero-dependency core plus React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters. State coordination, not UI ownership: Layers owns ordering, keys, transitions, blockers, and the `await client.open(...)` contract; you own rendering, focus, portals, and a11y.
Headless layer/stack manager — modals, dialogs, drawers, popovers, toasts are **layers in named stacks**. Open any layer from anywhere and `await` a typed result. A zero-dependency core plus React, Preact, Solid, Angular, Vue, Lit, Alpine, and Svelte adapters. State coordination, not UI ownership: Layers owns ordering, keys, transitions, blockers, and the `await client.open(...)` contract; you own rendering, focus, portals, and a11y.

> Experimental — the API may change between minor releases. Pin your version. ([Stability & versioning](https://stainless-code.com/layers/concepts/stability))

Expand All @@ -24,6 +24,7 @@ Full docs: [stainless-code.com/layers](https://stainless-code.com/layers).
| [`@stainless-code/angular-layers`](packages/angular) | `@angular/core` | `bun add @stainless-code/angular-layers` |
| [`@stainless-code/vue-layers`](packages/vue) | `vue` | `bun add @stainless-code/vue-layers` |
| [`@stainless-code/lit-layers`](packages/lit) | `lit`, `@lit/context` | `bun add @stainless-code/lit-layers` |
| [`@stainless-code/alpine-layers`](packages/alpine) | `alpinejs` | `bun add @stainless-code/alpine-layers` |
| [`@stainless-code/svelte-layers`](packages/svelte) | `svelte` | `bun add @stainless-code/svelte-layers` |

Install only the adapter for your framework — it pulls the core in transitively and re-exports it, so adapter APIs (`StackProvider`, `useStack`, …) and core APIs (`LayerClient`, `layerOptions`, …) both come from the one package. Each adapter lists its framework as a required peer. Svelte ships two entries: `@stainless-code/svelte-layers` (runes, 5.7+) and `@stainless-code/svelte-layers/store` (stores, 3+).
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/blume.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig } from "blume";
export default defineConfig({
title: "Layers",
description:
"Headless modal/dialog/drawer/popover/toast manager — open any layer from anywhere. Zero-dep core + React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters.",
"Headless modal/dialog/drawer/popover/toast manager — open any layer from anywhere. Zero-dep core + React, Preact, Solid, Angular, Vue, Lit, Alpine, and Svelte adapters.",

logo: { image: "/logo.svg", text: "Layers" },

Expand Down
184 changes: 184 additions & 0 deletions apps/docs/content/adapters/alpine.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
---
title: Alpine
description: Alpine adapter — plugin magics, x-layer-outlet, Rank-2 layerStack + callFor.
search:
tags: ["adapter", "alpine"]
sidebar:
icon: /icons/alpine.svg
---

Stop wiring `x-show` / `x-data` open flags for every modal — open a named stack from anywhere and `await` a typed result. `@stainless-code/alpine-layers` is the Alpine plugin + outlet; the core is re-exported.

:::tip
Alpine builds compiler-free (tsdown, no framework components shipped). Render with `x-layer-outlet` or Rank-2 `layerStack` + `x-for` + `callFor` in your markup — **intentional primitive design**, not a parity gap. See the [parity matrix](/adapters).
:::

## Install

```package-install
npm i @stainless-code/alpine-layers
```

**Peer:** `alpinejs` >=3.13

**Optional peer:** `@alpinejs/focus` >=3.13 — focus trap / inert backdrop (`x-trap.inert.noscroll`)

## Register the plugin

**Vite / bundled app** — register before `Alpine.start()`:

```ts
import Alpine from "alpinejs";
import layers from "@stainless-code/alpine-layers";

Alpine.plugin(layers);
window.Alpine = Alpine;
Alpine.start();
```

**CDN** — load the bootstrap **before** Alpine core (registers on `alpine:init`):

```html
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@stainless-code/alpine-layers/cdn"
></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"
></script>
```

The plugin registers `$layers`, `$layer`, `x-layer-outlet`, and `Alpine.data('layerStack', …)`.

## Client

Hold the client in the plugin closure — not `Alpine.store`. `getLayerClient()` lazy-inits on first access. Call `setLayerClient(client?)` only when you need to pin a custom client **before** first use (creates a `LayerClient` when omitted). `createStackHook({ client })` calls `setLayerClient` for you.

```js
import { setLayerClient } from "@stainless-code/alpine-layers";

setLayerClient(); // optional — only to pin a client before first stack use
```

:::note
`setLayerClient` after the singleton exists **throws**. Custom apps: set explicitly in bootstrap; `createStackHook` returns `{ setClient, useAppStack }`.
:::

## Render a stack

**Outlet** — declare markup inside `x-layer-outlet`; the directive evaluates the expression as a stack id. Keys by `state.id` and exposes the active row as `$layer` (`LayerComponentProps` bag — `$layer.call`, `$layer.payload`, …).

Always quote the id: `x-layer-outlet="'confirm'"`. Bare `confirm` resolves to `window.confirm` (a function) — the outlet warns in development and falls back to `"default"`.

```html
<template x-layer-outlet="'confirm'">
<div role="dialog">
<h2 x-text="$layer.payload.title"></h2>
<button type="button" @click="$layer.call.end(true)">Yes</button>
<button type="button" @click="$layer.call.end(false)">No</button>
</div>
</template>
```

:::tip
Outlet templates need a **single root** element (same Alpine convention as `x-for` / `x-if`). Wrap siblings in one `<div>` (or teleport target).
:::

**Rank-2** (subscribe + render yourself — no component registry) — `Alpine.data('layerStack', stackId?)` returns reactive `{ states, callFor(state) }`; pair with `x-for` where an outlet is not enough:

```html
<div x-data="layerStack('confirm')">
<template x-for="state in states" :key="state.id">
<div role="dialog">
<h2 x-text="state.payload.title"></h2>
<button type="button" @click="callFor(state)?.end(true)">Yes</button>
</div>
</template>
</div>
```

`layerOptions` has **no** `component` field — Alpine markup lives in the outlet / loop.

:::tip
Rank-2 `layerStack` + `x-for` + `callFor` is intentional — no shipped components. Focus trapping and scroll lock: optional [`@alpinejs/focus`](https://alpinejs.dev/plugins/focus); portaling: consumer [`x-teleport`](https://alpinejs.dev/directives/teleport), not the adapter.
:::

## Confirm dialog

```html
<div x-data="demo">
<button type="button" @click="remove()">Remove</button>

<template x-layer-outlet="'confirm'">
<div role="dialog">
<h2 x-text="$layer.payload.title"></h2>
<button type="button" @click="$layer.call.end(true)">Yes</button>
<button type="button" @click="$layer.call.end(false)">No</button>
</div>
</template>
</div>
```

```ts
import Alpine from "alpinejs";
import layers, {
createLayer,
layerOptions,
setLayerClient,
} from "@stainless-code/alpine-layers";

Alpine.plugin(layers);
setLayerClient();

const confirm = layerOptions<{ title: string }, boolean>({
stack: "confirm",
key: ["confirm", "remove"],
});

const confirmHandle = createLayer(confirm);

async function remove() {
const ok = await confirmHandle.open({ title: "Remove item?" });
// ^? boolean
}

Alpine.data("demo", () => ({ remove }));

window.Alpine = Alpine;
Alpine.start();
```

Prefer `createLayer` / `confirmHandle.open` from JS. `$layers.open` is the template-reachable facade over `client.open` (it lives in Alpine scope; a module-local `client` does not). Pass a full options bag that is in scope (e.g. put `confirm` on `Alpine.data`). For strict CSP, pair Alpine with [`@alpinejs/csp`](https://alpinejs.dev/advanced/csp).

## Primitives

Options bag plus optional trailing `LayerClient`. **Drive** with `createLayer(options, client?)`; **observe** with `createLayerState({ key, … })`. Core headless `createLayer` is re-exported as `createLayerHandle`.

| Export | Role |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `getLayerClient()` / `setLayerClient(client?)` | Singleton client in plugin closure (not `Alpine.store`); `get` lazy-inits; `set` only before first use |
| `useStack({ stack?, select?, compare? }, client?)` | Subscribe stack slice into Alpine reactivity |
| `useQueuedStack({ … }, client?)` | Queued snapshot binding |
| `createLayer(options, client?)` | **Drive** — wired handle + reactive `state` / `queued` / `top` |
| `createLayerState({ key, stack?, select?, compare? }, client?)` | **Observe** — mounted same-key layers |
| `createLayerQueuedState({ key, … }, client?)` | **Observe** — queued same-key layers |

## Ergonomic APIs

| Export / surface | Role |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `$layers` | Magic facade — `open`, `dismissAll`, `getStack` (per-layer dismiss via `$layer.call.dismiss`) |
| `$layer` | Current outlet row as `LayerComponentProps` inside `x-layer-outlet` |
| `x-layer-outlet="'<stackId>'"` | `<template>` outlet; id-keyed, directive `effect` + `cleanup` |
| `Alpine.data('layerStack', id?)` | Rank-2 `{ states, callFor(state) }` + subscribe teardown in `destroy()` |
| `useMutationFlow(call)` | Reactive `pending`; `run(fn).orEnd(response)` |
| `useLayerGroup(call, options?)` | `{ open, dismissAll, states, stackId, dispose }` — nest `x-layer-outlet` in markup; call `dispose()` when tearing down manually |
| `createStackHook(config?)` | `{ setClient, useAppStack }` |

No `component` registry — markup stays in your templates. Optional `Alpine.data('confirm', …)` is for reusable **behavior**, not template registration.

## Learn more

- [Lifecycle](/concepts/lifecycle) · [Identity & types](/concepts/identity-and-types)
- [Adapter parity](/adapters)
4 changes: 2 additions & 2 deletions apps/docs/content/adapters/angular.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ sidebar:
`@stainless-code/angular-layers` bridges stacks into Angular signals, rendered imperatively via `renderStack(vcr)`. The core is re-exported.

:::tip
Angular builds compiler-free (tsdown, no framework compiler). Stack rendering is **intentional imperative design** — `renderStack(vcr)` instead of a shipped `StackOutlet` component. This is not a parity gap; see the [parity matrix](/adapters).
Angular builds compiler-free (tsdown, no framework compiler). Stack rendering is **intentional imperative design** — `renderStack(vcr)` instead of a shipped outlet component. **Not a parity gap.** See the [parity matrix](/adapters).
:::

## Install
Expand Down Expand Up @@ -134,7 +134,7 @@ Options bag plus optional trailing `LayerClient`. **Drive** with `injectLayer(op
| `renderStack(vcr, stackId?, rootProps?)` | Imperative outlet — **the Angular rendering surface** |
| `useMutationFlow(call)` | `pending` is a `Signal<boolean>`; `run(fn).orEnd(response)` |
| `useLayerGroup(call, options?)` | Child stack with `renderInto(vcr)` instead of `Outlet` |
| `createStackHook(config?)` | `{ provideClient, useAppStack, renderInto }` — no `AppHost`/`AppLayer` |
| `createStackHook(config?)` | `{ provideClient, useAppStack, renderInto }` |

## Learn more

Expand Down
Loading
Loading