diff --git a/.docker/nginx.conf b/.docker/nginx.conf
new file mode 100644
index 0000000..123ee93
--- /dev/null
+++ b/.docker/nginx.conf
@@ -0,0 +1,41 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Health probe for Coolify. Kept trivial and unauthenticated so the proxy
+ # can check the container without an SSO session.
+ location = /healthz {
+ access_log off;
+ add_header Content-Type text/plain;
+ return 200 "ok\n";
+ }
+
+ # Vite writes a content hash into every asset filename, so these can be
+ # cached indefinitely. `^~` stops nginx from also testing the regex block
+ # below, which would otherwise claim any hashed `.json` asset.
+ location ^~ /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ try_files $uri =404;
+ }
+
+ # The entry pages and the story index must never be served stale: after a
+ # redeploy a cached index would point at asset names that no longer exist.
+ location ~* \.(html|json)$ {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+ }
+
+ # index.html for the manager, iframe.html for the stories themselves.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..105f72e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+# Never ship a host build into the image — it would mask a broken build.
+node_modules
+dist
+storybook-static
+*.tsbuildinfo
+
+.git
+.github
+.idea
+.claude
+.env
+.env.*
+
+docs
+specs
+*.md
+!README.md
diff --git a/.gitignore b/.gitignore
index 6de27aa..de79d95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ dist/
*.tsbuildinfo
.env
.claude/settings.local.json
+
+# Storybook static build
+storybook-static/
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 0000000..9553837
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,17 @@
+import type { StorybookConfig } from "@storybook/react-vite";
+import tailwindcss from "@tailwindcss/vite";
+import { mergeConfig } from "vite";
+
+const config: StorybookConfig = {
+ stories: ["../stories/**/*.stories.@(ts|tsx)"],
+ addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+ // Tailwind only ever runs here. The published package ships raw `.tsx` and
+ // `brand.css`; compiling utilities stays the consuming app's job.
+ viteFinal: (viteConfig) => mergeConfig(viteConfig, { plugins: [tailwindcss()] }),
+};
+
+export default config;
diff --git a/.storybook/manager.ts b/.storybook/manager.ts
new file mode 100644
index 0000000..15b63f6
--- /dev/null
+++ b/.storybook/manager.ts
@@ -0,0 +1,14 @@
+import { addons } from "storybook/manager-api";
+import { create } from "storybook/theming/create";
+
+// Brand the catalogue itself, so the two consumer teams can tell at a glance
+// which library they are looking at.
+addons.setConfig({
+ theme: create({
+ base: "light",
+ brandTitle: "Open Elements UI",
+ brandUrl: "https://github.com/OpenElementsLabs/open-elements-ui",
+ colorPrimary: "#5cba9e",
+ colorSecondary: "#020144",
+ }),
+});
diff --git a/.storybook/preview.css b/.storybook/preview.css
new file mode 100644
index 0000000..1499df4
--- /dev/null
+++ b/.storybook/preview.css
@@ -0,0 +1,14 @@
+@import "tailwindcss";
+@import "../src/styles/brand.css";
+
+@plugin "@tailwindcss/typography";
+
+/*
+ * The same content configuration a consuming app needs. Spec 001 recorded this
+ * as an unverified precondition: the library ships utility classes as source
+ * text, so they only become real CSS once something scans `src/`. If a story
+ * renders unstyled, the assumption was wrong — and it surfaces here rather
+ * than in an application.
+ */
+@source "../src";
+@source "../stories";
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 0000000..35f8971
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,21 @@
+import type { Preview } from "@storybook/react-vite";
+import type { ReactNode } from "react";
+import "./preview.css";
+
+const preview: Preview = {
+ parameters: {
+ controls: { expanded: true },
+ layout: "padded",
+ },
+ decorators: [
+ // Stand in for the consuming app's root element, which is where the brand
+ // body font and base colours are applied.
+ (Story: () => ReactNode) => (
+
+
+
+ ),
+ ],
+};
+
+export default preview;
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..94d616e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# syntax=docker/dockerfile:1
+
+# The component showcase: a static Storybook build served by nginx.
+#
+# A Dockerfile rather than Coolify's static build pack, so the build command and
+# the publish directory stay in version control and the whole thing is
+# reproducible locally with `docker build . && docker run -p 8080:80 `.
+
+# --- build ---------------------------------------------------------------------
+FROM node:24-alpine AS build
+
+WORKDIR /app
+
+# Node 24 to match .nvmrc; pnpm comes from the packageManager field.
+RUN corepack enable
+
+ENV CI=true
+ENV STORYBOOK_DISABLE_TELEMETRY=1
+# Corepack fetches the pinned pnpm on first use; never wait for a prompt.
+ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
+
+# Manifests first, so editing a story does not reinstall the toolchain.
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+RUN pnpm run build-storybook
+
+# --- serve ---------------------------------------------------------------------
+FROM nginx:stable-alpine AS serve
+
+COPY .docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/storybook-static /usr/share/nginx/html
+
+EXPOSE 80
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
+ CMD wget -q --spider http://localhost/healthz || exit 1
diff --git a/README.md b/README.md
index 4ad3ece..3771ac4 100644
--- a/README.md
+++ b/README.md
@@ -32,12 +32,53 @@ Import brand CSS in your app's stylesheet:
@import "@open-elements/ui/src/styles/brand.css";
```
+Because this package ships raw `.tsx`, its utility classes reach your app as source text. Tailwind
+only turns them into CSS if it scans the library, so the consuming app must point at it and load the
+typography plugin that `MarkdownView` relies on:
+
+```css
+@import "tailwindcss";
+@import "@open-elements/ui/src/styles/brand.css";
+@plugin "@tailwindcss/typography";
+@source "../node_modules/@open-elements/ui/src";
+```
+
+Without the `@source` line, components render unstyled — task list checkboxes pick up a `prose`
+bullet, for instance. The [component showcase](#component-showcase) uses the same configuration and
+asserts the result, so a break in this contract surfaces there.
+
## Translations
```typescript
import { de, en } from "@open-elements/ui";
```
+## Component Showcase
+
+Every component can be opened in isolation, with its props adjustable at runtime, in a Storybook
+catalogue:
+
+```bash
+pnpm storybook # dev server on http://localhost:6006
+pnpm build-storybook # static build into storybook-static/
+```
+
+Stories live in [`stories/`](stories/), deliberately outside `src/`: `files` in `package.json`
+publishes all of `src/`, so a colocated story would ship in the tarball and break a consumer's `tsc`
+on unresolvable `@storybook/*` imports. Nothing under `src/` imports Storybook, and Tailwind is a
+devDependency that no build script consumes — the published package is unaffected.
+
+Stories carry `play` functions, which run in a real browser and cover what jsdom can only check
+indirectly: the toolbar allowlist, the task-list creation gate under actual keystrokes, the
+checkbox lifecycle after actual clicks, and that the Tailwind utilities the components rely on
+resolve to real styles. They are additive; the vitest suites are unchanged.
+
+`MarkdownEditor` and `MarkdownView` establish the pattern. The remaining components follow
+incrementally.
+
+Deployment of the showcase is documented in
+[docs/showcase-deployment.md](docs/showcase-deployment.md).
+
## Releasing a New Version
Every release must be published to npm **and** have a corresponding Git tag and GitHub Release.
diff --git a/docs/showcase-deployment.md b/docs/showcase-deployment.md
new file mode 100644
index 0000000..3f4f9cb
--- /dev/null
+++ b/docs/showcase-deployment.md
@@ -0,0 +1,165 @@
+# Deploying the component showcase
+
+The showcase is the static Storybook build from this repository, served by nginx and put behind
+Authentik SSO. Nothing about the published npm package is involved.
+
+- **Image:** built from [`Dockerfile`](../Dockerfile) — `node:24-alpine` runs `pnpm build-storybook`,
+ `nginx:stable-alpine` serves `storybook-static/` on port 80.
+- **nginx config:** [`.docker/nginx.conf`](../.docker/nginx.conf). Hashed assets under `/assets/`
+ are cached for a year, `.html` and `.json` are `no-cache`, and `/healthz` returns `200 ok`.
+- **Access control:** enforced in the reverse proxy, not in the application. The build has no
+ awareness of it.
+
+Verify the image locally before touching Coolify:
+
+```bash
+docker build -t oe-ui-showcase .
+docker run --rm -p 8080:80 oe-ui-showcase
+curl -fsS http://localhost:8080/healthz # ok
+curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:8080/ # 200
+```
+
+## 1. The Coolify application
+
+Create an **Application** in the project of your choice:
+
+| Setting | Value |
+|---|---|
+| Source | this Git repository |
+| Branch | `main` |
+| Build pack | **Dockerfile** |
+| Dockerfile location | `/Dockerfile` |
+| Base directory | `/` |
+| Port | `80` |
+| Health check path | `/healthz` |
+| Domain | the showcase hostname (see [Open questions](#open-questions)) |
+
+A Dockerfile rather than the static build pack, so the build command and the publish directory stay
+in version control and are reproducible locally.
+
+Enable **auto-deploy** so a push to `main` redeploys. Coolify does this with a GitHub webhook: either
+connect the repository through a Coolify GitHub App, or copy the application's webhook URL into
+*Settings → Webhooks* on the repository. The `.dockerignore` keeps `node_modules`, `dist` and
+`storybook-static` out of the build context, so a deployment never picks up a stale local build.
+
+The first deployment should be checked **before** the auth middleware goes on — an unreachable
+container and a working middleware in front of a broken one look identical from the browser.
+
+## 2. The Authentik provider
+
+In Authentik:
+
+1. **Applications → Providers → Create → Proxy Provider**
+ - Name: `showcase`
+ - Authorization flow: your usual `default-provider-authorization-implicit-consent`
+ - Mode: **Forward auth (single application)**
+ - External host: `https://` — exactly the domain configured in Coolify,
+ including the scheme. A mismatch here sends users into a redirect loop.
+2. **Applications → Applications → Create**
+ - Slug: `showcase`, Provider: the provider above.
+3. **Bind the group.** On the application, add a policy binding for the group that is allowed in
+ (create one, e.g. `ui-showcase`, and add the two consumer teams). Without a binding every
+ authenticated user gets in, which defeats the point of using SSO over a shared password.
+4. **Outposts → the embedded outpost** → add the new application to it.
+
+## 3. Wiring the proxy
+
+Which of the two sections below applies depends on what the Coolify instance proxies with — see
+[Open questions](#open-questions). Both assume Authentik is reachable at `authentik-server:9000`;
+if it is not on a shared Docker network with the showcase container, substitute its public URL
+(`https://auth.`) everywhere `http://authentik-server:9000` appears.
+
+Two routes are always needed:
+
+- `/outpost.goauthentik.io/*` → the Authentik outpost, so the login handshake and the callback can
+ complete on the showcase's own hostname.
+- everything else → the showcase container, guarded by the forward-auth middleware.
+
+Because the middleware guards the whole host and the session cookie is set on that host,
+`iframe.html` and every hashed asset are covered by the same session. No extra rules are needed for
+them.
+
+### Traefik
+
+Add these as **custom labels** on the Coolify application (replace `ui.example.com`). Coolify manages
+`traefik.enable` and the TLS resolver for the main router itself; keep its generated labels and add
+these alongside.
+
+```
+traefik.http.middlewares.authentik.forwardAuth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
+traefik.http.middlewares.authentik.forwardAuth.trustForwardHeader=true
+traefik.http.middlewares.authentik.forwardAuth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
+
+traefik.http.routers.showcase.rule=Host(`ui.example.com`)
+traefik.http.routers.showcase.entryPoints=https
+traefik.http.routers.showcase.tls.certresolver=letsencrypt
+traefik.http.routers.showcase.middlewares=authentik@docker
+
+traefik.http.routers.showcase-outpost.rule=Host(`ui.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)
+traefik.http.routers.showcase-outpost.entryPoints=https
+traefik.http.routers.showcase-outpost.tls.certresolver=letsencrypt
+traefik.http.routers.showcase-outpost.service=authentik
+traefik.http.routers.showcase-outpost.priority=15
+
+traefik.http.services.authentik.loadbalancer.server.url=http://authentik-server:9000
+```
+
+The outpost router needs the higher `priority`: its rule is a strict subset of the showcase rule, and
+without it Traefik may pick the guarded router for the callback path and loop.
+
+### Caddy
+
+Caddy has no label interface; the routes go into the site block Coolify generates for the domain
+(*Configuration → Advanced → Custom Caddy configuration*):
+
+```caddyfile
+ui.example.com {
+ handle /outpost.goauthentik.io/* {
+ reverse_proxy http://authentik-server:9000
+ }
+
+ handle {
+ forward_auth http://authentik-server:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
+ trusted_proxies private_ranges
+ }
+
+ reverse_proxy showcase:80
+ }
+}
+```
+
+Note the endpoint differs from Traefik's: `/auth/caddy`, not `/auth/traefik`.
+
+## 4. Verification
+
+- [ ] A push to `main` triggers a Coolify deployment and the new build is served.
+- [ ] A visitor with no session requesting any path lands on the Authentik login flow.
+- [ ] A member of the bound group completes login and is returned to the requested path.
+- [ ] A user outside the bound group is refused and never receives the page.
+- [ ] Opening a story loads `iframe.html` and its assets with no second login prompt.
+- [ ] `/healthz` answers `200` for Coolify's probe.
+
+## Operational notes
+
+- **An expired session inside the story iframe.** Storybook renders each story in a same-origin
+ `