Architecture
Decisions
Settled by the team on 2026-08-25. They are recorded here rather than in the README because each one shapes everything built afterwards. Items still open live in the repository README's TO DISCUSS section.
Components are prefixed Hab*. It mirrors notitia-ui's Nta* and prevents collisions with Vuetify's v-* while Notitia is still mid-migration. The prefix is on the component, not the directory: src/components/Button/ contains HabButton.vue.
Tailwind v4 is the styling contract. Components compose utility classes from CSS-first @theme tokens. This does not force Tailwind on consumers: dist/habitus.css ships the compiled utilities, so an app that imports @livereader/habitus-vue/styles needs no Tailwind of its own. Only an app extending Habitus with its own utilities needs it, and it gets one shared token vocabulary by importing @livereader/habitus-vue/theme. See theming.
The token structure is settled, and so are the values. Fixed: the two layers (raw --hab-* properties re-pointed under .dark, mapped into Tailwind namespaces by @theme inline), the semantic naming (surface/fg/border) and the shared --spacing-control-* baseline. The values are sRGB hex and color-mix(), measured against the shipped product rather than chosen, and tests/palette.spec.ts and tests/contrast.spec.ts hold them there.
The one value that is deliberately not settled is --hab-accent: it ships an unbranded grey, and a product binds its own. Everything neutral derives from it, so see theming before changing it.
reka-ui 2.x is the primitive base. It is the maintained continuation of radix-vue, so wrapping it is the default for any new component rather than reimplementing focus management, keyboard handling or ARIA wiring. Habitus owns the tokens, the variant tables and the API shape; reka-ui owns the behaviour. That division matters most while the real-browser test layer does not exist — see Testing layers — because it is exactly the untestable-here behaviour we are choosing not to own.
Habitus ships no UI text. Every string the library can put on screen or into the accessibility tree comes from a prop, and none of them has an English default. Localisation is the host application's, which already owns a translation setup; supplying defaults would mean either shipping a locale layer for fifteen strings of chrome, or shipping English into applications that are not in English. There is little enough of it — a handful of icon-only button names, two placeholders, three empty states — that a prop per string is the whole mechanism.
Enforcement is warnOnce, not a required type: the props stay optional, and a missing one warns in development and is stripped from a production bundle. That catches consumers who do not typecheck their templates, and it degrades to an unnamed button rather than a failed build. The strings that are announced but never drawn all warn, because a missing one is invisible until someone listens to the page. The visible ones — placeholders, empty states — do not: an empty placeholder is its own evidence, and the empty states have slots.
Two consequences worth knowing. HabNavDrawer takes expandLabel andcollapseLabel, because one prop cannot name both states of its toggle once there is no default supplying the other half. HabChipInput.createLabel is a function of the typed text rather than a string, because that row interpolates and quotes it, and neither the word order nor the quotation marks („…“, « … ») are the same in every language.
Not covered: the 66 color names in HabColorPicker's palette data, which are a fixed design vocabulary rather than UI chrome, and HabMeter's showPercent, which formats a number rather than choosing a word — valueText is the way past it. Writing direction is a separate question: every directional utility in src/ is physical, and HabDataColumn.align still spells its values left/right.
Habitus is Vue-only. No sibling habitus-react, and no framework-neutral token package, until something actually needs React. Extracting tokens for a hypothetical second consumer means designing that boundary blind; if a React port ever lands, the extraction happens then, against a real second consumer.
Layout
src/
├── index.ts # public entry: createHabitus() + re-exports
├── components/
│ ├── index.ts # barrel — one line per component directory
│ └── Button/
│ ├── HabButton.vue # the component
│ ├── HabButton.spec.ts # colocated spec
│ ├── types.ts # props/variant types, re-exported publicly
│ └── index.ts # the directory's only public surface
├── composables/ # shared reactive logic
├── utils/ # framework-agnostic helpers
├── internal/ # never exported: shared helpers and HabToggleRow
├── types.ts # cross-component types (HabDensity)
├── global-components.ts # opt-in GlobalComponents types, a types-only subpath
└── styles/
├── theme.css # tokens -> ships as /theme
├── base.css # preflight + the two focus indicators -> /base
├── page.css # the page's ink and type on body, opt-in -> /page
├── index.css # Tailwind entry -> compiles to /styles
└── css.d.ts # empty declarations the CSS subpaths point `types` atOne directory per component, containing everything about that component. A spec lives next to the code it tests because that keeps the whole unit of work in one place.
Rules that are enforced
These are lint errors, not conventions:
- A component directory's
index.tsis its only public surface. Importing../Button/HabButton.vuefrom another component is an error; import../Button. Reaching past the index couples two components to each other's internals so neither can be refactored safely. - No
@/*alias imports insidesrc/.vue-tsccopies import specifiers verbatim into the emitted.d.ts, so an aliased import ships as a literal@/utils/cxthat no consumer can resolve. Relative imports only. The alias exists fordocs/, which is never published. - Props and emits are declared with types, not runtime objects.
vue-component-metareads types to generate the API tables; a runtime-object declaration produces an empty table rather than an error, which is exactly the kind of silent failure that rots documentation. - No
any. - No circular imports.
Rules that are checked in CI
CI runs npm run verify, which is nine steps: lint, format:check, typecheck, test, build, check:classes, check:exports, check:docs and docs:build. Three of them encode rules rather than correctness:
check:docsfails when a component lacks a docs page, when that page does not include its generated API table, or when the component is not exported from the barrel.check:classesmounts every component across its prop matrix and fails if any emitted class is absent fromdist/habitus.css— Tailwind emits nothing for a utility it cannot generate rather than erroring, so a renamed token otherwise ships a component with no background at all. It also enforces one property, one place: two unconditional utilities setting the same property are resolved by stylesheet order, not source order.check:exportsresolves every published subpath the way a consumer would; see Verifying the published surface.
Design constraints
The JS entry never imports CSS. src/index.ts importing the stylesheet would make the whole package one unconditional side effect and defeat tree-shaking. Consumers import @livereader/habitus-vue/styles explicitly. sideEffects in package.json is scoped to **/*.css accordingly.
ESM only. Every consumer is a Vite + Vue 3 app. A UMD/CJS build would double the output and the bug surface for a consumer that does not exist.
Per-module output. The build runs Rollup with preserveModules, mirroring src/ into dist/ one file per module, so importing one component does not pull in the rest. Note preserveEntrySignatures: "strict" is required: Vite only sets it for build.lib, and without it Rollup treats every public export as unused and emits empty files — a build that passes and ships nothing.
vue and reka-ui stay external. A second copy of reka-ui would break its provide/inject context sharing.
The CSS subpaths carry a types condition. TypeScript 6 rejects a side-effect import that resolves through exports without declarations (TS2882), so ./styles and ./theme both point their types at dist/css.d.ts, an empty module that exists only to be found.
Verifying the published surface
npm run check:exports runs are-the-types-wrong over a packed tarball, resolving every subpath the way a consumer would. It runs with --profile esm-only, since node10 and CommonJS failures describe consumers this package deliberately does not serve.
One rule is ignored: internal-resolution-error. The emitted declarations reference ./HabButton.vue, and node16 type resolution cannot follow a .vue specifier — an inherent consequence of shipping Vue SFC types, not something to work around. The bundler resolution mode that every consumer actually uses is clean, and the tarball is verified against a real Vite app.
Testing layers
Present today:
- Unit and behaviour — vitest +
@vue/test-utilson happy-dom. - Accessibility — axe-core via
tests/a11y.ts.tests/a11y.spec.tsverifies the harness itself still detects violations; if it fails, every a11y assertion in the repo has silently become a no-op.
Intended next, not yet set up:
- Real-browser interaction —
@vitest/browser+ Playwright, for focus management, keyboard navigation and pointer behaviour that happy-dom cannot model faithfully. - Visual regression — screenshot diffing per component variant.
Anything relying on real layout or focus behaviour should wait for the browser layer rather than being approximated in happy-dom.