From 25533187c09b9f89b69c5fff5977ccfc50093b69 Mon Sep 17 00:00:00 2001 From: Jessy Date: Mon, 24 Aug 2026 07:54:10 +0300 Subject: [PATCH] add storybook Signed-off-by: Jessy --- .docker/nginx.conf | 41 + .dockerignore | 17 + .gitignore | 3 + .storybook/main.ts | 17 + .storybook/manager.ts | 14 + .storybook/preview.css | 14 + .storybook/preview.tsx | 21 + Dockerfile | 38 + README.md | 41 + docs/showcase-deployment.md | 165 +++ package.json | 36 +- pnpm-lock.yaml | 1715 +++++++++++++++++++++++++-- specs/INDEX.md | 2 +- stories/markdown-editor.stories.tsx | 338 ++++++ stories/markdown-view.stories.tsx | 302 +++++ stories/support/editor.ts | 17 + stories/support/keyboard.ts | 23 + stories/support/panel.tsx | 38 + stories/support/use-mock-save.ts | 75 ++ tsconfig.json | 9 +- 20 files changed, 2804 insertions(+), 122 deletions(-) create mode 100644 .docker/nginx.conf create mode 100644 .dockerignore create mode 100644 .storybook/main.ts create mode 100644 .storybook/manager.ts create mode 100644 .storybook/preview.css create mode 100644 .storybook/preview.tsx create mode 100644 Dockerfile create mode 100644 docs/showcase-deployment.md create mode 100644 stories/markdown-editor.stories.tsx create mode 100644 stories/markdown-view.stories.tsx create mode 100644 stories/support/editor.ts create mode 100644 stories/support/keyboard.ts create mode 100644 stories/support/panel.tsx create mode 100644 stories/support/use-mock-save.ts 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 + `