Skip to content

Playbook: add a component

For humans and agents alike. Follow it in order; the checks at the end are what CI enforces.

Before writing code

  1. Confirm it belongs here. Habitus components are used by more than one app, or encode a design decision that should be consistent everywhere. A component only one screen needs belongs in that app.
  2. Find the reka-ui primitive. Check reka-ui before building behaviour by hand — it already solves focus management, keyboard interaction and ARIA wiring for most patterns. Wrapping a primitive is strongly preferred over reimplementing it.
  3. Read docs/guide/theming.md. Build from existing tokens. If none fits, that is a design decision — raise it rather than hard-coding a value.

Scaffold

Create src/components/<Name>/ — directory name without the prefix, e.g. Button for HabButton:

src/components/Dialog/
├── HabDialog.vue
├── HabDialog.spec.ts
├── types.ts
└── index.ts

types.ts — props interface and any variant unions. These live outside the SFC because <script setup> cannot export bindings, and consumers need them to type their own wrappers. Document every prop with a JSDoc comment: that comment becomes the description column in the generated API table.

HabDialog.vue<script setup lang="ts">, block order script → template → style. Declare props with withDefaults(defineProps<HabDialogProps>(), { … }). Compose classes from lookup tables rather than conditional strings; adding a variant should be a one-line change.

index.ts — the directory's only public surface:

ts
export { default as HabDialog } from "./HabDialog.vue";
export type { HabDialogProps } from "./types";

Register it in src/components/index.ts, alphabetically:

ts
export * from "./Dialog";

Copy src/components/Button/ as the reference — it is maintained as the pattern to follow.

Test

Colocate the spec. Cover the same four areas as HabButton.spec.ts:

  1. Rendering and slots — default output, each slot.
  2. The prop contract — every variant and size actually applies.
  3. State behaviour — disabled, loading, open/closed, error.
  4. AccessibilityexpectNoA11yViolations for the default state and for any state that changes roles, labels or focus.

Mount with { attachTo: document.body } for accessibility checks; axe needs a real document.

Keyboard and focus behaviour is worth asserting where happy-dom can model it, but do not fake what it cannot — note it for the browser-test layer instead.

Document

Demos are separate .vue files in docs/components/demos/, one per demo, referenced from the page by a single self-closing tag:

docs/components/demos/DialogBasic.vue   ->  <DialogBasic />

They are auto-registered by filename — no wiring needed. Each wraps the shared Demo component:

vue
<script setup lang="ts">
import { HabDialog } from "@/components/Dialog";
import Demo from "../../.vitepress/theme/Demo.vue";
</script>

<template>
	<Demo label="What this shows">
		<HabDialog>…</HabDialog>
	</Demo>
</template>

Do not put multi-attribute markup directly in the Markdown. Prettier reformats HTML embedded in .md, and singleAttributePerLine splits a multi-attribute tag across lines; Markdown then reads the orphaned > as a blockquote and any tab-indented child as a code block, silently unbalancing the tags and breaking the Vue compile. A self-closing tag has nothing to break. A .vue file also lets a demo hold state, which inline Markdown cannot.

Then create docs/components/Hab<Name>.md. Required structure:

md
# HabDialog

One sentence on what it is and when to use it.

<DialogBasic />

```vue
<HabDialog>…</HabDialog>
```

## <Sections for variants, sizes, states>

## Accessibility

What the component guarantees, and what the consumer is still responsible for.

## API

<!--@include: ./generated/HabDialog.md-->

Never hand-write the API table. The @include pulls in a partial generated from the source by npm run docs:api. A hand-written table drifts the moment someone changes a prop.

Add the page to the sidebar in docs/.vitepress/config.ts.

Wire up the issue template

Add the component to the component dropdown in .gitea/ISSUE_TEMPLATE/bug_report.yaml, so bugs can be filed against it. This is the one manual step nothing checks — it is easy to forget and quietly makes the template less useful over time.

Add a changeset

sh
npx changeset

Pick minor for a new component, and write the note for someone deciding whether to upgrade — what they gain, not what you did.

Verify

sh
npm run verify

That runs exactly what CI runs: lint, format, typecheck, tests, build, and the docs check. Then look at it for real:

sh
npm run docs:dev

Confirm the demos render, the generated API table matches what you declared, and the component looks right in both light and dark mode (use the appearance toggle).

Definition of done

  • [ ] Directory follows the layout above; index.ts is the only public surface
  • [ ] Every prop has a JSDoc description
  • [ ] Wraps a reka-ui primitive where one applies
  • [ ] No hard-coded colours or spacing — tokens only
  • [ ] Spec covers rendering, props, states and accessibility
  • [ ] Docs page exists with live demos and the generated @include
  • [ ] Demos are .vue files in docs/components/demos/, referenced by a single tag
  • [ ] Added to the components barrel and the docs sidebar
  • [ ] Added to the bug-report issue template dropdown
  • [ ] Changeset added
  • [ ] npm run verify passes
  • [ ] Checked in light and dark mode

Proprietary — internal use only.