@layerfi/components — Layer's embeddable React accounting component library, published to
npm from src/index.tsx. Consumers nest our components under LayerProvider and brand them
with CSS variables, so everything is themeable, localizable, and mountable more than once
on a page. Most conventions below exist to protect one of those three properties.
- Before creating a new definition, check whether a similar implementation already exists in the repo; extend it instead of adding a parallel one
- Generate designs and code in reviewable phases
- Do not add comments when writing code, beyond a short note on a genuinely non-obvious constraint
- Pause and ask for guidance when: task scope is unclear, complications arise, or a phase completes
- Never recreate a file that is missing from the tree without asking — deletions are intentional
- Verify with
npm run typecheck,npm run lint, andnpm test -- --runbefore handing work back - Do not run
npm install/npm i— ask first; dependency changes belong in their own PR - PRs follow
.github/PULL_REQUEST_TEMPLATE.md: Description, Changes, Blockers, How this has been tested
Each area has a colocated SKILL.md. Read the relevant one(s) before making changes there.
| Working on… | Read |
|---|---|
| Which layer code belongs in, feature-domain boundaries, fixing a boundary error | src/SKILL.md |
API contracts, Effect Schema, enums, nullability, envelopes, money types |
src/schemas/SKILL.md |
| Fetching, mutating, caching — SWR hook factories, cache tags, invalidation | src/hooks/api/SKILL.md |
| Feature/util hooks — which directory, composition and return conventions | src/hooks/SKILL.md |
| Zustand stores, contexts, providers, feature visibility | src/providers/SKILL.md |
Pure helpers — whether one belongs in utils, and where |
src/utils/SKILL.md |
Internal types — features/ vs shared/ vs utility/ |
src/types/SKILL.md |
| Component structure, loading/empty states, responsive UX | src/components/SKILL.md |
| Design-system primitives, style props, variant data attributes | src/components/ui/SKILL.md |
| Building a form — fields, validators, submit and error handling | src/components/blocks/Form/SKILL.md |
| Building a data table — variant choice, columns, row behaviour | src/components/blocks/Table/SKILL.md |
| SCSS, CSS variables, BEM naming, property order | src/styles/SKILL.md |
| Translated strings, plurals, the Crowdin pipeline | src/assets/locales/SKILL.md |
| Formatting money, numbers, percentages, dates, durations | src/utils/shared/i18n/SKILL.md |
| Mocking endpoints — MSW handlers, stateful stores | src/msw/SKILL.md |
| Fixture data — handwritten factories vs generated rows | src/fixtures/SKILL.md |
| Writing tests | src/testUtils/SKILL.md |
| Stories and visual regression | .storybook/SKILL.md |
Those skills plus this file are the only convention docs — each is the single source of truth
for its area, so update the relevant one when a convention changes rather than describing it
somewhere new. Cross-cutting rules (TypeScript, imports, lint, commands, CI) live here, because
they belong to no single directory. .cursor/BUGBOT.md and
.augment/code_review_guidelines.yaml are the same idea
for the PR reviewers — the two say the same thing in each tool's format, so update both whenever a
non-negotiable here changes, or they drift silently.
The remaining root docs are not agent instructions:
README.md is consumer-facing usage and PUBLISHING.md is the
release process.
| Path | Alias | Contains |
|---|---|---|
src/schemas |
@schemas/* |
Effect schemas — the source of truth for every API contract, split into features/<domain>/ and common/ |
src/types |
@internal-types/* |
internal-only types (no wire format), split into features/<domain>/, shared/<capability>, utility/ (type-level helpers) and ambient/ (global declarations) |
src/utils |
@utils/* |
pure helpers, split into features/<domain>/ (domain-aware) and shared/<capability>/ (api, swr, i18n, date, form, number, zustand, styles, …) |
src/hooks/api/** |
@api/* |
one file per endpoint in a tree mirroring the REST path, named for the HTTP method (get.ts, post.ts, …) |
src/hooks/{features,utils,legacy} |
@hooks/* |
composed feature logic · generic hooks · pre-factory hooks (don't extend) |
src/providers |
@providers/* |
scoped Zustand stores and DI contexts, split into features/<domain>/, global/ (the LayerProvider stack) and common/ (domain-agnostic) |
src/components/ui |
@ui/* |
design-system primitives (domain-agnostic) |
src/components/blocks |
@blocks/* |
composed patterns: tables, cards, wizards (domain-agnostic) |
src/components/features/<domain> |
@features/* |
feature UI, one directory per domain object; fetches its own data |
src/components/utility |
@components/utility/* |
rendering helpers: ConditionalBlock, ResponsiveComponent, withRenderProp |
src/views |
@views/* |
full-page compositions that mount providers |
src/styles |
— | design tokens and base CSS, bundled to dist/index.css |
src/msw |
@msw/* |
mock API, mirroring the same route tree as hooks/api |
src/fixtures |
@fixtures/* |
fixture factories, generators, and committed generated/*.gen.ts |
src/testUtils |
@testUtils/* |
LayerTestProvider, form fillers, fixed dates, story helpers |
Two lint-enforced rules. Full detail, including why each layer sits where it does and how to fix a
violation, is in src/SKILL.md — read it before moving code between directories.
Layers. Every file belongs to one layer and may import strictly lower layers only:
- foundation —
@internal-types@schemas@utils@icons@assets - context —
@providers/global@providers/common(never fetches) - generic hooks —
@hooks/utils - data loading —
@api - stores —
@providers/features@hooks/legacy - feature hooks —
@hooks/features - render helpers —
@components/utility - primitives —
@ui - patterns —
@blocks - feature UI —
@features - views —
@views - app root —
LayerProvider,src/index.tsx
Context sits below the hooks because the SWR factories read LayerContext; stores sit below
feature hooks because that is the direction the dependency already ran, 38 to 7.
Feature domains. In schemas, components, hooks, providers and utils, a domain under
features/ may import itself plus that partition's declared shared set — nothing else. The sets
live in eslint.config.mjs.
Tests and stories are exempt from both rules; production source still may not import @msw,
@fixtures, @testUtils or story modules. A domain name means the same thing in every partition.
- Imports: always the most specific alias. No relative parent imports (
../), no barrelindex.tsfiles, style imports last. Import order is lint-enforced — runlint:fix, don't sort by hand. Details under TypeScript and imports below. - Components: build on
@uibefore creating anything new; new reusable primitives go insrc/components/ui.<HStack>/<VStack>instead of<div>,<Span>/<P>/<Label>instead of raw text elements. - Styling: no
styleprop, no inline styles, no utility class strings, no concatenated class names. Colors and spacing come fromsrc/styles/variables.scss. Prefer component props over new CSS; express variants asdata-*attributes viatoDataProperties. Write flat, greppable selectors — nest only modifiers of the current selector, never&__Element. - Strings: every user-visible string — including
aria-label, table headers, and empty states — goes throught('ns:category.key', 'Default')with an inline default. Never hand-editsrc/assets/locales/**; it's generated from code and Crowdin. - Formatting: never format money, numbers, percentages, or dates by hand. Use
useIntlFormatter()/<MoneySpan>. Currency inputs are cents; percent inputs are fractions; dates take aDateFormatenum value, never a format string. - Data: no
useSWRorfetchin feature code — use the factories in@hooks/utils/swr.businessIdand auth are injected; never pass them from a component. When consuming a new backend response, stop and ask for the API contract rather than guessing the schema. - State: SWR owns server state, Zustand owns UI state, Context is for DI. Never mirror
server data into a store. Never call
setStateduring render, and avoiduseEffectandsetStatefor values you can derive — seesrc/components/SKILL.md. - Stabilize deliberately:
useCallbackonly for props tomemo()ed children or hook dependency arrays;useMemoonly for expensive computations, object/array props tomemo()ed children, or dependency-array values. Never memoize primitives. Do memoize object literals returned from custom hooks.
Each of these has broken something before:
- Unmocked requests fail. Vitest and Storybook both error on unhandled
layerfi.comcalls. A new endpoint needs an MSW handler registered in the enclosinghandlers.ts. Schema.NullishOris the default for optional/nullable API fields — the backend omits a field on one endpoint and returnsnullon another.- Never hand-write snake_case JSON. Mocks hold decoded fixtures and encode through the schema, so wire-format changes propagate automatically.
- Committed fixtures go stale. Touching a fixture schema or generator means
npm run fixtures:generateand committing the.gen.tsoutput; CI checks it. - Raw
BigDecimalin form or React state triggers TS2589. UseNonRecursiveBigDecimal. - CSS variables only exist under the design-system root classes (
.Layer__component,.Layer__Portal, …). Portals and bare primitives in stories need one on an ancestor. - Locale is part of every SWR cache key, so switching locale refetches. Leave
isLocalizedat its default. src/mswmay not value-import@api/*or@hooks/*— handlers load before per-test mocks apply and would break unrelated suites. Share contracts via@schemas.- Import boundaries are lint-enforced on both axes — tiers and feature domains. See
Import boundaries; the table lives at the top of
eslint.config.mjs. - Every
@apimethod file needs an MSW handler at the mirrored path insrc/msw/api;npm run msw:check-coverageenforces it in CI. - Production source may not import
@msw/*,@fixtures/*,@testUtils/*, or*.stories*. Tests and stories are exempt from the tier and domain rules, but not from this one. - Responsiveness is measured in JS, not media queries — hence
ResponsiveComponentand Chromatic's per-width iframe resizing. - Every story is a Chromatic snapshot. Pack primitive variants into one gallery story rather than adding a story per variant.
react-hooks/exhaustive-depsis an error. Fix the dependencies; don't disable the rule.- Pushing
.github/workflows/changes needs the SSH remote — HTTPS pushes are rejected.
Reach for these before writing your own:
| Need | Use |
|---|---|
| One data object with loading/error/inactive states | ConditionalBlock |
| An array with loading/error/empty states | ConditionalList |
| Different components per width | ResponsiveComponent |
| Empty/error/loading visuals | DataState, SkeletonLoader, SkeletonTableLoader |
| Tables | SimpleDataTable, DataTable, PaginatedDataTable, ExpandableDataTable, VirtualizedDataTable |
| Forms | useAppForm + the Form*Field components; validators in @utils/shared/form/validators |
| Pagination state | @hooks/utils/pagination (usePaginationState, useTablePaginationProps) |
| A GET / paginated GET / write | createQueryHook · createInfiniteQueryHook · createMutationHook |
| Cache invalidation after a write | createResourceGlobalCacheActions + useOnTriggerSuccess |
| Variant styling on a primitive | toDataProperties + data-* selectors |
strict is on (noImplicitAny, strictNullChecks, noImplicitOverride,
useUnknownInCatchVariables). Vite transpiles; tsc only typechecks.
- No
any. Useunknownand narrow. Avoidascasts; when one is genuinely required at a boundary, add a short comment saying why. - Prefer
typealiases; useinterfacewhen you need declaration merging or self-reference (recursive schemas require it — seesrc/schemas/SKILL.md). - Derive types instead of restating them:
typeof Schema.Type,Parameters<typeof useHook>[0],Pick<RawThing, …>,ReturnType<…>. readonly/ReadonlyArrayfor data you don't own;asMutable(@utils/shared/array/asMutable) at the boundary of an API that demands a mutable array.- Shared utility types live in
src/types/utility/**:OneOf(exclusive unions),EnumWithUnknownValues(open string enums),pagination,awaitable,table. Check there before writing a new type-level helper. src/types/**is for internal-only types with no wire format. Anything the API sends or receives is a schema. Runtime code (classes, data tables, guards) belongs with its owner insrc/utilsor the feature — seesrc/types/SKILL.md.- Not yet enabled in
tsconfig.jsonbut worth honoring:isolatedModules,verbatimModuleSyntax,noUncheckedIndexedAccess.
Aliases, most specific first: @ui/*, @blocks/*, @features/*, @components/*, @api/*, @hooks/*,
@providers/*, @utils/*, @internal-types/*, @schemas/*, @views/*, @icons/*,
@assets/*, @msw/*, @fixtures/*, @testUtils/*.
simple-import-sort sorts imports by layer, lowest first, so a block reads bottom-up through the
stack. Run lint:fix; never sort by hand. Adding an alias touches two files — see
src/SKILL.md.
Type imports are inline-style and enforced: import { type Foo } from '…'.
Single quotes (in JSX too), no semicolons, 2-space indent, 160-char lines, newline at EOF,
operators before line breaks, _-prefix to intentionally ignore a binding, console limited to
warn/error/debug.
Don't add explanatory comments describing what the code does. A short comment is warranted only for a non-obvious constraint — a workaround, a required ordering, why a cast is safe, etc.
| Command | What |
|---|---|
npm test |
vitest watch (-- --run for one pass) |
npm run typecheck |
tsc --noEmit |
npm run lint / lint:fix |
ESLint + stylelint, with auto-fix |
npm run storybook |
Storybook on :6006 |
npm run fixtures:generate / fixtures:check |
regenerate / verify committed fixtures |
npm run dev |
watch-build the library (dev:js + dev:types) |
npm run build |
production build (build:js esm + cjs, then build:types) |
Every PR runs eslint, stylelint, typecheck, vitest, build, bundle-size (fails past a
growth budget), fixtures (staleness check), storybook, chromatic, and npm-audit.
@layerfi/components ships from src/index.tsx; prepack runs typecheck plus a clean build.
See PUBLISHING.md and the release-* workflows. Adding an export to
src/index.tsx is a public API change — call it out in the PR. It also creates a subpath:
build:exports reads that file and generates dist/exports/<Name>.{mjs,cjs,d.mts,d.cts} for every
public name, so @layerfi/components/<Name> resolves. Nothing to maintain by hand, but the new
subpath is part of the same API commitment.
build:js produces three outputs — per-module ESM, a bundled dist/cjs/index.cjs for the .
require condition, and a per-module dist/cjs/modules/** reached only through the subpath shims.
The CJS barrel stays bundled on purpose: require never tree-shakes, so splitting it only made
require('@layerfi/components') slower.