Skip to content

Creating a Component ​

A qs-* component is an Elena element with its own tokens, scoped CSS, a docs page and an entry in the design spec. Build it by hand following the steps below, or ask your coding agent to run the qs-component skill, which walks the same steps and stops at each gate.

1. Spec ​

Write down the tag (qs-<name>, kebab-case), its props, zones, states and variants. Pick the native element first: <button>, <dialog>, <h1>… ARIA only fills what the native element can't do.

2. Tokens ​

Add a component.qs-<name> group to packages/components/src/tokens/tokens.json. Component tokens reference alias tokens only ({type-size.body}, never a raw value). Then regenerate the CSS:

sh
npm run gen:tokens:css      # tokens.json -> tokens.css, fails on a layer skip
npm run gen:tokens:penpot   # keep the Penpot export in sync

3. JavaScript ​

Create packages/components/src/qs-<name>/qs-<name>.js. Copy the shape of an existing component: qs-text for a simple one, qs-button for one with ARIA state, qs-dialog for one with several content zones.

js
import { Elena, html } from "@elenajs/core";
import { placeContent, watchContent } from "../shared/content.js";

/**
 * One-line description of the component.
 *
 * @displayName QsBadge
 * @status alpha
 */
export default class QsBadge extends Elena(HTMLElement) {
  static tagName = "qs-badge";
  static props = ["variant"];

  /**
   * The style variant.
   * @property
   * @type {"default" | "danger"}
   */
  variant = "default";

  /** @internal */
  willUpdate() {
    this._content ??= [...this.childNodes];
  }

  /** @internal */
  render() {
    return html`<span class="badge badge-${this.variant}"></span>`;
  }

  /** @internal */
  firstUpdated() {
    watchContent(this);
  }

  /** @internal */
  updated() {
    placeContent(this, this.element);
  }
}

QsBadge.define();

Child content ​

Elena's first render replaces the host's children. Follow Elena's contract: children are the static content of the first render, and dynamic text comes through Elena's built-in text property (React text={…}, Vue :text, Angular [text]). Capture the children in willUpdate() (so links, <em> and lang spans keep their markup), and place the content from updated() with placeContent() from shared/content.js: it shows the children, or text when a framework set it. Call watchContent(this) in firstUpdated() as a safety net for a framework that rewrites the children anyway.

Accessibility ​

  • Set ARIA attributes in updated(), not in the template: a template can't omit an attribute, so aria-label="" would always render. See qs-button.
  • Icon-only controls need a label prop; an aria-label on the host never reaches the inner element.
  • <a> has no disabled state: drop href and add aria-disabled="true".
  • Never put an id on a heading inside a component: the docs outline lists every heading with an id, hidden dialogs included. Name a dialog with aria-label.

4. Styles ​

Put qs-<name>.css next to the JS, wrapped in @scope (qs-<name>).

  • Every value is a token: the CSS reads only --qs-<name>-*, --text-*, --focus-* and, for a form field, the shared --field-* (with shared/field.css and shared/field.js), never a literal colour, size or duration.
  • Focus uses :focus-visible with the --focus-outline-* tokens.
  • Hover motion sits inside @media (hover: hover) and (pointer: fine).
  • Reduced motion keeps colour and fades, and drops movement.
  • Contrast holds in both themes: 4.5:1 for text, 3:1 for borders and focus rings.

5. Register ​

Add the export to packages/components/src/index.js:

js
export { default as QsBadge } from "./qs-badge/qs-badge.js";

6. Gates ​

Run each check; all must pass before the component is done:

sh
npm run gen:tokens:css
npm run gen:utilities:css
npm run gen:tokens:penpot
npm run validate:component -- --all
npm run build:all      # library, every app in apps/, docs
node --test docs/.vitepress/tokens.data.test.mjs scripts/lib/tokenLayers.test.mjs
npm run docs:build
npm run test:e2e      # Playwright: docs pages, one test per component, every app page (axe)

The qs-component skill runs all of these in order with bash .agents/skills/qs-component/scripts/verify.sh, plus a check that every component token is used.

What the validator checks ​

validate:component checks the source statically against the Elena pattern:

  • imports Elena and html from @elenajs/core
  • export default class <ClassName> extends Elena(HTMLElement), where <ClassName> is the PascalCase of the tag (qs-badge → QsBadge)
  • static tagName matches the tag name
  • a static props = [ ... ] array
  • a render() method returning an html\`` template
  • <ClassName>.define() as the final statement
  • a leading JSDoc block with @displayName and @status alpha
  • balanced braces and parentheses

Errors fail the check; warnings (for example, an import from a package other than @elenajs/core) are printed but don't block.

sh
npm run validate:component -- qs-button          # one component by tag
npm run validate:component -- --all              # every qs-* component

Browser tests ​

tests/e2e/components.spec.mjs holds one Playwright test per component, run in your installed Chrome against the docs site. Add one for a new component, and keep it next to the behaviour it guards: when that behaviour changes, update the test in the same commit.

7. Docs ​

Add docs/components/<name>.md, starting with <ComponentHeader tag="qs-<name>" />, and a sidebar entry in docs/.vitepress/config.mjs. The props table comes from the JSDoc through custom-elements.json, so npm run build:lib before npm run docs:dev.

Then check the component in the browser: both themes, keyboard only, 320px wide and reduced motion.

8. Spec ​

Add the component to the Components table in design/design.original.md, then regenerate the compressed design/design.md from it.

Pre-commit safety net ​

A native git hook (.githooks/pre-commit) runs validate:component -- --all whenever anything under packages/components/src is staged (JS, CSS or tokens), so an invalid component can't be committed. It installs automatically on npm install via the prepare script (git config core.hooksPath .githooks).

To bypass it for a single commit:

sh
git commit --no-verify