Skip to content
Open
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
41 changes: 41 additions & 0 deletions .docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ dist/
*.tsbuildinfo
.env
.claude/settings.local.json

# Storybook static build
storybook-static/
17 changes: 17 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions .storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
});
14 changes: 14 additions & 0 deletions .storybook/preview.css
Original file line number Diff line number Diff line change
@@ -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";
21 changes: 21 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="font-body text-foreground bg-background">
<Story />
</div>
),
],
};

export default preview;
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 <image>`.

# --- 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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
165 changes: 165 additions & 0 deletions docs/showcase-deployment.md
Original file line number Diff line number Diff line change
@@ -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://<showcase-hostname>` — 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.<domain>`) 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
`<iframe>`. If the session expires while the tab is open, that frame receives a redirect to
Authentik, which refuses to be framed — the story area goes blank rather than showing a login
page. Reloading the top-level page fixes it. Raising the Authentik session duration reduces how
often anyone meets it.
- **Base images are tag-pinned, not digest-pinned.** `node:24-alpine` matches `.nvmrc`;
`nginx:stable-alpine` tracks the nginx stable branch. Pin digests if the deployment ever needs to
be bit-for-bit reproducible across time.
- **`build-storybook` is deliberately not in CI.** It would roughly double pipeline time to guard
tooling. The Coolify build is the thing that catches a broken story build today — a failed
deployment is the signal.

## Open questions

These are environment facts, not decisions, and they are the reason section 3 documents two variants:

- **The hostname**, and who creates the DNS record for it.
- **Traefik or Caddy** on the Coolify instance — it decides which half of section 3 applies.
- **Whether Authentik and the showcase container share a Docker network.** If they do not, every
`http://authentik-server:9000` above becomes Authentik's public URL.
Loading
Loading