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
- 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.
- 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.
- 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.tstypes.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:
export { default as HabDialog } from "./HabDialog.vue";
export type { HabDialogProps } from "./types";Register it in src/components/index.ts, alphabetically:
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:
- Rendering and slots — default output, each slot.
- The prop contract — every variant and size actually applies.
- State behaviour — disabled, loading, open/closed, error.
- Accessibility —
expectNoA11yViolationsfor 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:
<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:
# 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
npx changesetPick minor for a new component, and write the note for someone deciding whether to upgrade — what they gain, not what you did.
Verify
npm run verifyThat runs exactly what CI runs: lint, format, typecheck, tests, build, and the docs check. Then look at it for real:
npm run docs:devConfirm 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.tsis 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
.vuefiles indocs/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 verifypasses - [ ] Checked in light and dark mode