AGENTS.md@apps/storybook · diff
git:20261014.3ded334 to git:20261020.8d47d7a
84 added, 190 removed. Audit A to A.
# Agent Instructions — `apps/storybook`
- The unified component workbench. Read together with the
- [root `AGENTS.md`](../../AGENTS.md) and [`README.md`](README.md).
-
- ## What's in here
+ The workbench for the login surface: one `@storybook/web-components-vite`
+ instance hosting `@zitadel/components`' atoms and orchestrators, the
+ orchestrators driven by `@zitadel/api-mock`. Console UI iterates on the console
+ dev server instead (ADR 055). Commands and coverage: [`README.md`](README.md).
- A single `@storybook/web-components-vite` instance that hosts `@zitadel/components`
- — the Lit atoms and the orchestrators (`<zitadel-login>`, `<zitadel-session>` —
- see `src/session.stories.ts`) — so atoms can be checked against Figma and the
- orchestrators can be driven against `@zitadel/api-mock`. This is the workbench
- for the **login surface only**; console UI iterates on the console dev server
- (ADR 055). It is a dev tool, not a published npm package (`private`): it ships
- no package `dist`. It does build a static site into `dist/` via `storybook:build`
- — deployed as the hosted cloud's `storybook` service at `/storybook/` (repo-root
- [`vercel.json`](../../vercel.json); production from `main`, a preview per branch
- that touches it), and the `dist` output convention is in
- [`apps/AGENTS.md`](../AGENTS.md). That build runs in CI (Vercel is a CI
- environment), as does `storybook:test`.
+ The static build (`dist/`) is the hosted cloud's `storybook` service at
+ `/storybook/`: production from `main`, a preview on every branch. Vercel builds
+ it with `turbo run build` (repo-root [`vercel.json`](../../vercel.json)).
## Hard rules
- - **One instance, one renderer.** Everything here is a custom element and
- renders natively. Do not add Storybook Composition, a second Storybook, or a
- React renderer — that's the pattern this app exists to avoid.
- - **One component, controls for state.** A story is the single component driven
- by `args`/`argTypes` controls — not a grid of static instances and not one
- story per state. Use a control (e.g. `previewState`) to flip interaction
- states. Do not reintroduce hand-rolled "matrix" stories.
- - **One story file per atom, one story in it.** `src/<id>.stories.ts` exports
- `Default` under an `Atoms/<Name>` title. Don't split an atom across files or
- add a redundant second (`Playground`) level.
- - **Tokens, not magic values.** Visuals come from `@zitadel/design-tokens`
- (imported once in `.storybook/preview.ts`). An atom's CSS is owned by
- `packages/components/src/atoms/zl-<id>.css`; stories never restyle atoms.
- - **Behaviour lives in the source packages; don't duplicate upward.** This app
- gates render + a11y for every story. Add a `play` function only when no lower
- layer proves the behaviour — the atoms own toggle/form/focus in
- `packages/components` specs, so their stories generally carry no `play`.
- - **MSW handlers are orchestrator-only.** `msw-storybook-addon` starts one
- worker for the whole preview (`.storybook/preview.ts`); only the orchestrator
- stories give it handlers, through `parameters.msw`. The atoms make no
- requests, and the orchestrator stories are tagged `no-test` to keep network
- out of the test run.
- - **The static build is relocatable.** The cloud serves it under `/storybook/`,
- on the same origin as the console and the login UI. Address files relative
- to the page or to Vite's base, never from `/`. The MSW worker follows the
- base (`.storybook/preview.ts`), which keeps its scope inside `/storybook/`:
- a worker scoped to `/` would intercept the console's and the login UI's
- requests.
-
- ## Orchestrator stories & the flow engine
-
- The `<zitadel-login>` orchestrator renders **whatever the flow engine returns** —
- it has no opinion of its own. In Storybook that engine is `@zitadel/api-mock`
- (an xstate machine + fixtures), driven through `msw-storybook-addon`. So its
- controls split in two, grouped with `argTypes.<name>.table.category`:
-
- - **Component** — host props set on the element: `purpose`, `variant`, `theme`,
- `previewState`.
- - **Flow engine** — what the backend returns (applied as mock overlays in
- `beforeEach`, not element props): `branding`, `sso`, `passkey`.
-
- ### Theming has two separate systems — do not conflate them
-
- - **Atoms** read the design-system default tokens, which carry both modes, and
- switch on `data-theme` on `<html>` (dark `:root`, light `:root[data-theme=…]`).
- - **The orchestrator does NOT read document `data-theme`.** It paints from the
- **branding** the flow returns, resolved by `ThemeController`. The `theme`
- property can override, with this precedence (`theme-controller.ts`):
- 1. **If the branding publishes only one palette side, that side wins and
- `theme` is ignored** (you can't select a mode with no colours).
- 2. otherwise: element `theme` → `branding.theme.mode` → variant default
- (`page` → dark, `widget` → auto).
- So the `theme` control only visibly toggles when the selected `branding` ships
- **both** sides: `centered` (now two-sided), `two-sided`, and `none` (no
- branding → design-system defaults) toggle; `dark` / `light-only` / `split` are
- single-sided and force their one side. This is the #1 source of "the theme
- toggle doesn't work" confusion.
-
- ### What each control is, and where it comes from
-
- - **`variant`** (`widget` | `page`) — a **host prop**, front-end only. `page`
- fills its container and paints its own background; `widget` is content-sized
- and transparent for embedding. It does not come from the backend.
- - **`layout`** (`centered` | `split`) — comes from the **backend**
- (`branding.layout`), there is **no** element prop for it. `split` is **retired**
- (#1039): it only renders via the legacy `*-template` liquid presets, not the
- default template — a bare `layout: "split"` is a no-op. Don't add a `layout`
- control expecting split to render.
- - **`branding`** — backend-returned; there is no `branding` prop. The Storybook
- presets (`branding-presets.ts`) are fixtures overlaid via `applyBranding`.
- `none` applies nothing → design-system defaults.
- - **`sso`** — backend overlay via `applySsoProviders`, a scalar `off` / `on` so
- it round-trips in the URL. The provider **list is derived**, not hard-coded:
- `on` offers every brand mark `@zitadel/components` ships
- (`SHIPPED_BRAND_ICON_NAMES`, mapped `brand-google` → a Google provider) plus one
- synthetic provider with no mark, to keep showing the labelled-button fallback.
- Add a glyph in `@zitadel/components` and it appears here automatically — don't
- re-introduce a hard-coded Google/GitHub list, and don't depend on
- `@zitadel/config` (Storybook depends on `@zitadel/components`, not config).
- - **`passkey`** — backend overlay via `applyPasskey` (off by default). When on,
- the identifier step gains a "Sign in with a passkey" action. Both overlays are
- off by default so the mock keeps mirroring the shipped flow (and the api-mock
- conformance spec stays green).
-
- ### Shareable links
-
- Storybook encodes args in the URL (`?path=…&args=branding:dark;sso:on`), so
- every control change is a shareable link. Keep controls **scalar** (enums /
- booleans) so they serialise cleanly; editable object/array controls do **not**
- round-trip and break the shareable-link guarantee.
-
- ### Stateful-element isolation (don't remove)
-
- `.storybook/preview.ts` keys each mock-backed story's subtree on the story id
- **and args**, so switching stories or flipping a knob rebuilds the stateful
- `<zitadel-login>`. Without it, a flow driven to its terminal "signed-in" step
- leaks into the next render and the next story shows blank.
-
- ### Step coverage (and what can't be a story)
-
- One-story-with-knobs is the **atom** rule; the orchestrator additionally has
- step-journey stories, because a backend-driven step can only be shown by driving
- the flow to it (via `play`). Covered: `identifier` (SignIn), `register` (SignUp),
- `password` (PasswordStep), `register-password` (RegisterPasswordStep), `recover`
- (RecoverStep), `done` (SignedIn), `register-sso` (RegisterAfterProvider),
- `sso-conflict` (ConflictAfterProvider), identifier + providers, identifier +
- passkey offer (PasskeyOffered), and the passkey ceremony step `passkey-login`
- (PasskeyLogin).
-
- `passkey-login` is special: the step mounts an invisible `<zl-passkey>` that
- auto-runs a real `navigator.credentials.get()` ceremony on mount — the OS passkey
- prompt, which can't complete in the workbench. The suppression is kept **in the
- workbench, not on the shipped component**: `freezePasskeyCeremony()`
- (`orchestrator-shared.ts`) stubs `navigator.credentials` with a promise that never
- settles (the same `Promise.race([])` stub the component specs use), so the atom
- stays in its `pending` state and shows its real "waiting for your passkey" UI
- instead of raising the prompt. It mocks a browser API the same way `msw` mocks the
- network; the story installs it in `beforeEach` and the returned teardown restores
- `navigator.credentials` so no other story is affected. This is deliberately a
- test-harness stub, not a host prop on the component — the login element has no
- "skip the ceremony" flag, and we are not adding one. `<zl-passkey>` has no atom
- story for the same reason (its only visible state is the in-flight ceremony).
-
- **Not storied on purpose:** `passkey-setup` / `passkey-upsell` (legacy; the
- default flow no longer routes through them — don't revive them to force a story),
- and `sso-redirect` (transient — it navigates away).
+ - One instance, one renderer: no Storybook Composition, second Storybook or
+ React renderer.
+ - One story file per atom (`src/<id>.stories.ts`), exporting one `Default`
+ under `Atoms/<Name>`. States are controls (`args`/`argTypes`, e.g.
+ `previewState`), never extra stories or "matrix" grids.
+ - Visuals come from `@zitadel/design-tokens`; an atom's CSS is
+ `packages/components/src/atoms/zl-<id>.css`. Stories never restyle atoms.
+ - Add a `play` only when no lower layer can prove the behaviour; atoms prove
+ theirs in `packages/components` specs. This app gates render and a11y.
+ - Only orchestrator stories give the one MSW worker handlers
+ (`parameters.msw`), and they are tagged `no-test` to keep the network out of
+ the test run.
+ - The build is relocatable: address files relative to the page or Vite's
+ base, never from `/`. The MSW worker follows the base; scoped to `/`, it
+ would intercept the console's and login UI's requests.
- The password field renders with name **`x-auth-methods#password`** (the schema
- pointer, exported as `PASSWORD_FIELD`), not `"password"` — use `PASSWORD_FIELD`
- when a `play` fills or waits on it.
+ ## Orchestrator stories
- ### Verify permutations for real
+ `<zitadel-login>` renders whatever the flow engine returns; here that engine is
+ `@zitadel/api-mock`. Group controls with `argTypes.<name>.table.category`:
+ **Component** for host props (`purpose`, `variant`, `theme`, `previewState`),
+ **Flow engine** for mock overlays applied in `beforeEach` (`branding`, `sso`,
+ `passkey`).
- When you change a control or preset, **load the stories and read the component's
- resolved state** — host `data-theme`, provider-button count, the rendered step —
- across branding × theme × sso. Do not assume from the code; the single-sided
- gating above has repeatedly made "looks done" wrong.
+ - The orchestrator ignores the document's `data-theme`: it paints from the
+ flow's branding (`theme-controller.ts`). A branding with one palette side
+ forces that side, so the `theme` control toggles only with two-sided
+ presets.
+ - `layout` comes from the backend and has no element prop. `split` renders
+ only through the legacy `*-template` presets, so never add a `layout`
+ control.
+ - `branding` presets (`branding-presets.ts`) are fixtures applied through
+ `applyBranding`; `none` leaves the design-system defaults.
+ - `sso` providers derive from `SHIPPED_BRAND_ICON_NAMES`
+ (`orchestrator-shared.ts`), so a glyph added in `@zitadel/components` shows
+ up on its own. Never hard-code a provider list.
+ - `sso` and `passkey` stay off by default, so the mock mirrors the shipped
+ flow and the api-mock conformance spec stays green.
+ - Keep controls scalar (enums, booleans): args round-trip through the URL, and
+ object controls break shareable links.
+ - Never remove the story-id-and-args key on the mock-backed subtree in
+ `.storybook/preview.ts`: without it, a flow driven to `done` leaks into the
+ next story.
+ - `passkey-login` stories stub `navigator.credentials` with
+ `freezePasskeyCeremony()` in `beforeEach`. Keep that stub in the workbench;
+ the login element gets no "skip the ceremony" prop.
+ - Not storied on purpose: `passkey-setup` / `passkey-upsell` (legacy; the
+ default flow no longer routes through them) and `sso-redirect` (it
+ navigates away).
+ - A `play` that fills the password uses `PASSWORD_FIELD`
+ (`x-auth-methods#password`), never `"password"`.
+ - After changing a control or preset, load the stories and read the resolved
+ state (host `data-theme`, provider buttons, the step) across branding ×
+ theme × sso. Do not infer it from the code.
## Recipe: add an atom (Figma → Storybook)
- The repeatable flow that built `select`. Each step links the package whose
- scoped `AGENTS.md` owns the rules — read those rather than rediscovering them.
+ Each step's package has its own `AGENTS.md`; follow it.
- 1. **Design.** Pull the frame with the Figma MCP (`get_design_context` /
- `get_screenshot`); note the states, the WAI-ARIA pattern, and which tokens it
- uses. A missing value is a new token in `@zitadel/design-tokens` — never a
- magic value in CSS.
- 2. **Icon (only if new).** Add the glyph to
- `packages/components/src/atoms/zl-icon.ts` (`IconName` union +
- `SHIPPED_ICON_NAMES` + `ICON_NODES`).
- 3. **CSS** (`packages/components`, see its `AGENTS.md`): create
- `src/atoms/zl-<id>.css` beside the atom — `:host`/slot rules first, then the
- painted `.zr-<id>` surface. One file; there is no separate host sheet.
- 4. **Lit atom** (`packages/components`, see its `AGENTS.md`): create
- `src/atoms/zl-<id>.ts` — `static formAssociated = true` if it holds a value,
- import the CSS with `./zl-<id>.css?inline`, and export a manifest. Register:
- export from `src/atoms/index.ts`, add the manifest to `src/manifests.ts`, and
- re-export the public API from `src/index.ts`.
- 5. **Tests — lowest layer that proves the property** (don't duplicate upward):
- - `src/atoms/zl-<id>.spec.ts` (jsdom: markup + ARIA).
- - `src/atoms/zl-<id>.browser.spec.ts` (chromium: form participation,
- keyboard, focus) — only if it has real-platform behaviour.
- - Add the tag to `src/manifests.spec.ts` and its events to
- `src/atoms/event-contract.spec.ts`.
- 6. **Story** (`src/<id>.stories.ts`): one `Default` story under an
- `Atoms/<Name>` title. States are knobs, not extra stories.
- 7. **Verify.** Dev loop: `storybook dev -p 6006` (HMR from source). Gate:
- `moon run components:test` (unit + browser projects), `moon run
- storybook:typecheck`, and `moon run storybook:test` (render + a11y + plays in
- Chromium). For just the fast unit lane while iterating, use
- `pnpm --filter @zitadel/components test`.
+ 1. **Design.** Pull the frame with the Figma MCP (`get_design_context`,
+ `get_screenshot`); note states, the WAI-ARIA pattern and tokens. A missing
+ value is a new token in `@zitadel/design-tokens`.
+ 2. **Icon**, if new: `packages/components/src/atoms/zl-icon.ts` (`IconName`,
+ `SHIPPED_ICON_NAMES`, `ICON_NODES`).
+ 3. **CSS**: `src/atoms/zl-<id>.css` beside the atom, `:host`/slot rules first,
+ then the painted `.zr-<id>` surface.
+ 4. **Atom**: `src/atoms/zl-<id>.ts`. An atom that holds a value extends
+ `FormAtom` (`src/internal/form-atom.ts`); never hand-roll
+ `formAssociated`. Import the CSS with `?inline`, export a manifest, export
+ the atom from `src/atoms/index.ts`, add the manifest to `src/manifests.ts`
+ and the public API to `src/index.ts`.
+ 5. **Tests**, at the lowest layer that proves the property: `zl-<id>.spec.ts`
+ (jsdom: markup, ARIA), `zl-<id>.browser.spec.ts` (Chromium: form, keyboard,
+ focus) only if needed, the tag in `src/manifests.spec.ts` and its events in
+ `src/atoms/event-contract.spec.ts`.
+ 6. **Story**: `src/<id>.stories.ts`, one `Default` under `Atoms/<Name>`.
+ 7. **Verify** with `moon run storybook:dev` (HMR from source), then
+ `moon run components:test storybook:typecheck storybook:test`. While
+ iterating, `pnpm --filter @zitadel/components test:unit` is the fast lane.
## Local checks
```sh
- moon run storybook:typecheck
- moon run storybook:build
- moon run storybook:test
+ moon run storybook:typecheck storybook:build storybook:test
```
- `storybook:test` launches real Chromium and re-bundles its dependencies, the
- generated API client among them, on every start (`optimizeDeps.force` in
- `vitest.config.ts`), so a local run starts as cold as a CI run. Don't pipe it
- through `tail` (buffers until EOF) and don't kill-and-retry without first
- clearing strays (`pkill -f vitest; pkill -f chrome-headless-shell`), or the next
- run stalls on `Port … is in use`. See
- [`packages/components/AGENTS.md`](../../packages/components/AGENTS.md#the-browser-project-hangs--it-doesnt-its-cold-start).
+ `storybook:test` runs real Chromium and re-bundles its dependencies on every
+ start (`optimizeDeps.force`), so every run starts cold. Never pipe it through
+ `tail`, and clear strays before a retry; see
+ [`packages/components/AGENTS.md`](../../packages/components/AGENTS.md#the-browser-projects-cold-start).