Imported from zitadel/nextgen (
packages/components/AGENTS.md). Install upstream withnpx skills add zitadel/nextgen --skill components. Copyright stays with the author.
Components Agent Notes
Scoped instructions for packages/components/. Read together with the
root AGENTS.md.
What's in here
Lit-based atoms (<zl-*>) and the orchestrators for the auth flow API:
<zitadel-login> (flow runner), <zitadel-logout>, and <zitadel-session>
(session surface), all under src/orchestrator/. Consumed directly by tenant
pages and indirectly by the apps/console shell. See README.md
for consumer-facing docs.
Theming
Mode resolution is owned by src/orchestrator/theme-controller.ts +
surface.ts, which stamp data-theme / data-theme-dark on the host. The
precedence is strongest-first: the embedding page's theme property → stored
branding theme.mode → a variant-derived default (dark for
variant="page", auto for variant="widget") — see amended ADR 014 §5 and
theme.browser.spec.ts. Never hardcode mode-specific colors in orchestrator
CSS; consume the semantic tokens, which flip via [data-theme="light"].
The orchestrator calls @zitadel/api (orval-generated typed fetch
client) through the wrappers in src/orchestrator/api-client.ts. There is
no transport abstraction, no wire-vs-internal type split, and no shadow
types — the orchestrator stores the orval CreateFlow201 shape directly
and the Liquid renderer reads it through the LiquidContext projection.
Tests intercept at the network layer with MSW. The unit tests and the
Storybook orchestrator stories both source their handlers from
@zitadel/api-mock (setupMockHandlers() for msw/node; the same handlers
via msw-storybook-addon in apps/storybook).
Configure the SDK with configureZitadel({ projectId, proxyPath })
from @zitadel/api/config; the element reads the global handle via
getZitadelConfig(), or you can assign the returned handle to the element's
project property. There is no api-base attribute.
Type boundaries
There are exactly three places types live in this package:
- Wire shapes — never declared here; imported from
@zitadel/api/generated/model(CreateFlow201,CreateFlow201Step,CreateFlowBody,SubmitFlowStepBody, …). - Branding —
src/orchestrator/branding.tsnames the branding shape the orchestrator paints (Branding,BrandingPalette,BrandingShape,BrandingTheme,BrandingThemeSide,BrandingTypography,FlowLayout). The appearance blocks are wire fields, so these are aliases of the generated model, not declarations.attributionis the one real client extension: it belongs to the embedding, not to the stored revision. Do not add an appearance key here — add it toapi/openapi/and re-export it. - Template context —
src/orchestrator/template-context.tscarries the projection tenant Liquid templates can reference (FlowMessage,FlowIdentity,FlowError[],LiquidContext). Lifted from the wire by the orchestrator at render time.
Do not introduce new shadow types that mirror orval. If the wire shape is fine, use it directly.
Source-level conventions
Lit reactivity — always use accessor
All @property and @state declarations must use the accessor keyword:
@property() accessor name = "";
@state() private accessor hasHelp = false;
Plain class fields (@property() name = "";) shadow Lit's auto-generated
accessors when the build target is es2022 or any toolchain that sets
useDefineForClassFields: true (Vite, tsdown, esbuild). Lit logs a console
error in dev mode and silently fails to detect changes in prod. The fix is
not "turn off the warning" — it is the accessor keyword.
Atoms are form-associated by default
Every input atom (e.g. <zl-field>) must:
- declare
static formAssociated = true, - attach
ElementInternalsin the constructor, - mirror its value via
internals.setFormValue()and validity viainternals.setValidity(), - implement
formResetCallbackandformStateRestoreCallback, - enable
delegatesFocus: trueon its shadow root.
This is what makes password managers, browser autofill, native form submission
and validation work through Shadow DOM. The decision is documented in
docs/design/branding/form-participation.md;
read that before touching zl-field or adding new input atoms.
jsdom only partially implements ElementInternals. Form-association tests
go in *.browser.spec.ts files (real Chromium); jsdom-friendly aria/markup
checks go in *.spec.ts.
Input atoms expose a real native control (agent-first)
The Zitadel CLI is an agent-facing product: an agent must be able to fill the
generated auth UI and register a user. Automation drivers — Playwright
(selectOption, fill, getByTestId), the chrome-devtools accessibility
snapshot, the Codex in-app browser, password managers, and screen readers — all
operate through real native form controls and the accessibility tree, not
through hand-rolled ARIA widgets in Shadow DOM. A purely custom widget (a
<div role="combobox"> + <ul role="listbox">, for example) is opaque to them:
its options never surface as targetable nodes.
So every input atom must make its operable, accessible, automatable control a real native element:
<zl-field>→ native<input>.<zl-checkbox>→ native<input type="checkbox">.<zl-select>→ native<select>with real<option>s.
That native element must:
- carry a stable
data-testidof the formzitadel-<kind>-<name>(e.g.zitadel-input-email,zitadel-checkbox-newsletterOptIn,zitadel-select-maritalStatus), so tests/agents target it by name; - re-dispatch a composed native
change(andinputwhere applicable) across the shadow boundary, and keepinternals.setFormValue()in sync on every change — so a value set directly on the native control (autofill, an agent, programmaticselectOption) is still captured before submit; - stay in the accessibility tree and the tab order. Visually hide it with
opacity: 0/pointer-events: none(see<zl-checkbox>,<zl-select>surface CSS) — neverdisplay: none,visibility: hidden, oraria-hidden, which would drop it from the a11y tree and the tab order.
When a design needs styling the native control can't provide (e.g. a custom
dropdown popup), keep the styled UI as a pointer-only visual layer: mark it
aria-hidden and out of the tab order so it doesn't duplicate the native
control for AT/agents, and route its state through data-* hooks rather than
ARIA. The native control remains the single source of truth; the visual layer
just mirrors value and feeds the same change path. <zl-select> is the
reference implementation. This convention was established in PR #279 (fields,
buttons) and extended to selects.
No CSS.escape in runtime code
jsdom 29 does not ship CSS.escape. Code that runs in tests must not rely
on it — query the DOM directly (querySelectorAll + getAttribute filtering)
rather than building attribute selectors with CSS.escape(value).
Templates are sanitised
Output from the Liquid pipeline is run through DOMPurify with an allowlist
built from manifestRegistry. When you add a new attribute or part to an atom,
update its manifest in manifests.ts so the sanitiser keeps it. Anything not
in the allowlist is silently stripped.
Tokens, not magic values
Atom styles must consume design tokens through the t helper
(src/styles/tokens.ts), which wraps the @zitadel/design-tokens cssVars
tree as Lit CSSResult values (e.g. t.color.surface.defaultWhite). Tokens
themselves are owned by the @zitadel/design-tokens package — add new ones
there, not here. The orchestrator maps branding tokens to CSS variables on
its own shadow root — do not reach for inline styles in atoms.
Comments
Comments explain why, not what. Skip comments like
// increment counter or // import the module. Useful comments call out
trade-offs, browser quirks, or constraints the code itself can't convey.
Tests
Two Vitest projects, one config (vitest.config.ts):
unit—jsdom. The fast local lane; whatpnpm testruns.browser— Chromium via@vitest/browser-playwright. Run alone withpnpm test:browser. Tests in*.browser.spec.tsonly.
Both run together in CI via pnpm test:all (what moon run components:test
invokes), producing one aggregated junit.xml.
Always cover form-associated behaviour, focus delegation, and Enter-to-submit in the browser project. Anything markup-only (aria attributes, classes, slot projection) belongs in the unit project for speed.
The browser project "hangs" — it doesn't, it's cold-start
The browser project launches real headless Chromium via Playwright and
resolves the whole workspace from source (resolve.conditions: ["@zitadel/source"]). On a cold run Vite pre-bundles a large graph (lit, the
generated @zitadel/api client, dompurify, liquidjs) before the first
test: that warm-up is 60–120s+ and variable, while the tests themselves run in
~1s. With a warm .vitest cache the same run finishes in seconds. CI
pays this cold start deliberately: moon ci :... :test runs test:all (both
projects), and the components:test task pulls in Chromium via an
install-browsers moon dep. Locally, pnpm test stays on the fast jsdom
unit project.
What actually makes it look stuck, and how to avoid it:
-
Don't pipe through
tail(or any buffering filter). It withholds all output until EOF, so the entire warm-up looks frozen. Let it stream. -
Don't kill a run mid-flight and immediately retry. An interrupted run leaves a zombie Chromium + Vite dev server holding the port; the next run logs
Port 63315 is in use, trying another one...and stalls retrying. If you did interrupt one, clear it first:pkill -f vitest; pkill -f chrome-headless-shell -
Run it directly and patiently, expecting a slow first run then fast reruns:
corepack pnpm --filter @zitadel/components test:browser -
For a single file:
corepack pnpm --filter @zitadel/components exec vitest run --project browser <name>.
vitest run exits non-zero on any failure, so a 0 exit is authoritative even
when the non-TTY summary line doesn't flush to a redirected log.
When a test needs the Flow API, prefer setupMockHandlers() from
@zitadel/api-mock over hand-rolled handlers — it walks the same
xstate machine the Storybook orchestrator stories use, so step fixtures and
step routing stay consistent.
The full sign-in handover (terminal step → session exchange → real
Set-Cookie on the demo origin → full-page navigation to a protected
route) is covered end-to-end in apps/demo-next-e2e/ and
apps/demo-nuxt-e2e/, not here. The terminal handoff_token is exchanged
through the generated exchangeSession wrapper in api-client.ts (which
hits the SDK proxy path); there is no session-exchange-path attribute. Unit
coverage lives in api-client.spec.ts and zitadel-login.spec.ts. When a
change touches maybeCompleteFlow or the postSignInUrl path, run both
e2e projects — they exercise different SDK middlewares against the same
orchestrator code, which is how a regression in one framework slips past the
other.
When iterating against a long-running demo dev server, remember the demo
loads this package's built dist/ (not source). The Moon e2e tasks depend on
the relevant build tasks so CI is safe; manual loops need a fresh
moon run components:build after orchestrator changes.
Workbench
The interactive workbench is apps/storybook
(moon run storybook:dev, :6006): the Lit atoms and the
<zitadel-login> orchestrator (MSW via msw-storybook-addon). It loads the built dist/, so rebuild after source
changes (moon run components:build) — the Storybook tasks depend on it.
Build
tsdown.config.ts produces ESM + .d.mts and externalises lit, liquidjs,
dompurify, and the @zitadel/api* packages so npm consumers dedupe
with their own copies. Do not bundle those in by default — that breaks shared
instances and bloats the package.
Don't
- Don't bypass Lit's render with manual
innerHTMLmutation. UseunsafeHTMLinside arender()(the orchestrator already does this for Liquid output; atoms should never need it). - Don't add a
templateaccessor on<zitadel-login>casually — it changes the public surface. There is an open follow-up to do this properly with per-tenant template caching; coordinate before implementing. - Don't ship a new atom without a manifest entry, a unit spec, a browser spec
if it owns user input, and an entry in the orchestrator's sanitiser
allowlist (which reads from
manifestRegistryautomatically — but verify with a spec). - Don't re-introduce a
FlowResponse/FlowStep/FlowFieldalias that duplicates orval. If the wire isn't usable directly, fix that inpackages/api/orval.config.tsso the typed client improves for everyone.
