Every tool in RevyHubX is a vertical slice: one directory under features/
that owns its logic, validation, state, UI, tests, fixtures, request mocks and
documentation. Nothing about a tool lives outside its own directory.
This document is the specification each tool issue is written against.
npm run verify:features enforces it, and CI runs that check on every pull
request.
The usual layout — components/, lib/, hooks/, tests/ — forces every
contributor to edit the same shared files. With dozens of open pull requests
that produces constant conflicts in constants.ts, barrel files and route
tables, and none of the work is really independent.
Here, adding a tool means creating one new directory and editing nothing else:
- Routing is a single dynamic route,
app/tools/[slug]/page.tsx. You do not create a route file. - The registry is generated from the directories under
features/byscripts/generate-registry.mjs, and the generated files are gitignored. You do not edit a registry, a navigation list, or a constants file. - Navigation, the dashboard and search all read that generated registry, so a new tool appears in all three the moment its directory exists.
- The registry is split into metadata and lazy loaders:
manifestis eager, but each entry exposesload: () => import("@/features/<slug>/panel"). That keeps the shared nav/search bundle metadata-only and avoids pulling every feature panel into the initial JavaScript.
The structural validator in scripts/generate-registry.mjs enforces the
registry budget automatically: every feature must keep its metadata in
manifest.ts, its panel in panel.tsx, and it may not import another feature's
internal modules. A directory is rejected with a file-and-slice-specific error if
its slug duplicates another tool, its manifest omits required fields, or it
crosses feature boundaries.
Two contributors working on two different tools cannot conflict, because they never touch the same file.
features/<slug>/
├── manifest.ts registry metadata
├── panel.tsx default export used by the route
├── types.ts the slice's own types and error codes
├── schema.ts raw input → validated request
├── copy.ts every user-facing string
├── lib/
│ ├── <domain>.ts the tool's logic
│ ├── <domain>.errors.ts transport failures → this tool's codes
│ └── format.ts presentation helpers
├── hooks/
│ └── use<Name>.ts the state machine
├── components/
│ ├── <Name>Panel.tsx composes the states
│ ├── <Name>Form.tsx input
│ ├── <Name>Result.tsx success
│ └── <Name>EmptyState.tsx the pre-interaction state
├── __tests__/
│ ├── <domain>.test.ts logic
│ ├── schema.test.ts validation
│ ├── format.test.ts formatting
│ ├── use<Name>.test.tsx hook
│ ├── <Name>Panel.test.tsx component
│ └── a11y.test.tsx axe, WCAG 2.1 A/AA
├── fixtures/
│ └── <domain>.fixture.ts deterministic sample data
├── msw/
│ └── handlers.ts request mocks (required if it makes requests)
├── e2e/
│ └── <slug>.spec.ts end-to-end specification
└── README.md what it does, how, and why
Minimum 20 files. A complete slice lands at 23-25 without padding.
Scaffold the whole structure with:
npm run new:feature -- <slug> "<Title>" <category>Anything the user can cause is a value, not an exception. Use
Result<T, Code> from @/core/result/result:
if (!StrKey.isValidEd25519PublicKey(accountId)) return err("invalid_address");
return ok({ accountId });Error codes are the interface — never English strings. The UI maps codes to copy. That is what makes messages translatable, testable and refactorable.
request_failed is a last resort. A user who typed one wrong character and a
user whose account does not exist on the selected network need different
advice, so they get different codes. Every code must map to copy that says what
to do next.
No user-facing text inside components. Tests import copy and assert against
it, so wording changes never break the test suite.
Stellar amounts have 7 decimal places and can exceed Number.MAX_SAFE_INTEGER,
so they are never parsed as floats. Every slice that accepts or displays an
amount uses @/core/format/amount:
- Input —
parseAmountaccepts one fixed, locale-independent grammar: ASCII digits, an optional leading-, and.as the only decimal separator, with at most seven decimals. A comma, space, apostrophe or underscore is rejected withgrouping_separatorinstead of being guessed at:1,234is 1234 in en-US and 1.234 in de-DE. Map that code to copy telling the user to write1234.5. - Display —
formatAmountshows the value in the user's locale (grouping, decimal separator, digit shapes) throughIntl.NumberFormat, but only the whole part goes throughIntl, as aBigInt; the fraction digits are appended unchanged, so no locale ever rounds a stroop away.
Dates use formatDateTime / formatUnixSeconds from @/core/format/date:
the user's locale decides the shape, and the time zone is always UTC and
printed, so ledger times can be compared against explorers.
Idle (empty state), loading, success, error. The panel composes them; no state is skipped because it "rarely happens".
If a tool reads the chain, its result belongs to the network it came from.
Store the network beside the state and derive staleness during render — do not
reset in an effect. useBalanceViewer is the reference.
a11y.test.tsx runs axe over the real rendered markup and must report zero
WCAG 2.1 A/AA violations, in at least two states — usually initial and
"result shown". Use Field from @/core/ui so labels, hints, aria-invalid
and aria-describedby are wired correctly for free.
Announce errors once. A field-level error already carries role="alert", so do
not also render a banner for it — see TrustlineCheckerPanel.
Hand-written Stellar addresses have wrong checksums. Derive them:
const seed = (byte: number) =>
Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte));
export const accountId = seed(1).publicKey();Mock at the network boundary so the test exercises the real client. Call
resetHorizonClients() before a request when a test changes handlers — the
Horizon client is memoised per network.
Reject anything starting with S on the prefix alone, before any checksum
check, and never render it back. features/address-validator has two tests
asserting the seed appears in neither component output nor hook state. Every
slice that takes an address needs the equivalent.
Having a __tests__/ directory proves nothing about behaviour. Every slice's
tests must drive it through its loading, error and empty states
using the shared harness in @/core/testing/contract:
import { renderFeatureSlice } from "@/core/testing/contract";
const slice = renderFeatureSlice("balance-viewer", <BalanceViewerPanel />);
slice.expectEmptyState(); // idle, before any input
await user.click(screen.getByRole("button", { name: copy.submit }));
await slice.waitForState("loading"); // request in flight
await slice.waitForState("error"); // request failedThe harness asserts against the data-contract-state attribute that the shared
state primitives render — EmptyState ("empty"), StatusMessage ("error",
"success", …) and Skeleton/SkeletonRows ("loading") — instead of against
copy. That is what lets the same three helpers work for a lookup form, a QR
generator and a wallet-connect flow. A slice with a bespoke indicator (a
spinner, a skeleton it renders itself) can carry
data-contract-state="loading" on it and get the same treatment.
npm run verify:features reads each slice's __tests__/ sources: if they never
call expectLoadingState(), expectErrorState() and expectEmptyState() (or
the generic expectState("…") / waitForState("…") forms), the slice fails
with a message naming the state it skips. Slices that predate this rule are
listed in scripts/feature-contract-migration.json and are reported as a
warning instead of a failure. That list is a migration ledger: it must only
shrink, and new slices are never added to it.
The checker itself is covered by core/__tests__/verifyFeaturesContract.test.ts
against the deliberately minimal good and bad fixture slices under
scripts/__tests__/fixtures/, so a regression that makes the rule always pass
(or always fail) fails the normal test run.
A feature may import its own files, core/, and external packages. It must not
import another feature, including through a deep relative path. Move genuinely
shared code into core/. npm run verify:boundaries enforces this in CI and
has both alias and relative-path failure fixtures. An exception requires a
documented architectural reason and maintainer approval in
scripts/feature-boundary-exceptions.json; inline suppressions are
intentionally unsupported. The checked-in entries are legacy migration debt,
not examples to copy.
NetworkContext exposes a monotonically increasing epoch. The provider keys
its feature boundary by {network}:{epoch}, so switching networks immediately
unmounts every result and in-flight state. Async callbacks from the previous
epoch therefore cannot overwrite the new network's UI. Network-aware hooks
must use useNetwork; do not create a separate network preference or cache.
Tools that deliberately query multiple networks independently must declare
networkEpochIndependent: true in their manifest so the verifier can
distinguish that design from an accidental context bypass.
Every generated registry module exports generatedRegistrySchemaVersion.
Navigation, search, panel lookup, and verify:features assert that it matches
FEATURE_REGISTRY_SCHEMA_VERSION. To change the format: update the generator,
consumers, schema constant, and old-schema regression test in one pull request;
then run npm run registry. Treat each increment as a small registry-format
changelog in the pull request description.
npm run check # registry + lint + test + verify:features + buildAll four must pass, plus:
-
npm run verify:features -- <slug>reports OK - Tests drive loading/error/empty through
@/core/testing/contract - Every error code has copy that says what to do next
-
a11y.test.tsxcovers at least two states -
README.mdexplains the non-obvious decision in the slice, not just what the tool does - No secret key is ever accepted or echoed
- Nothing outside
features/<slug>/was modified
That last line is the important one. If your pull request touches a shared file, something is wrong with the approach — say so in the issue rather than working around it.
The reasons behind these rules are recorded as architecture decision records in adr/ — in particular 0001 (slices), 0002 (generated registry), 0003 (error codes), 0004 (fixtures and MSW), 0005 (runtime states) and 0006 (amounts and dates).
A pull request that changes this document — a rule, the layout, or what
verify:features enforces — must add a new ADR or supersede an existing one in
the same pull request. See adr/README.md for the format.