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.
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.
Use strings and BigInt. formatAmount in features/balance-viewer is the
reference.
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.
npm run check # registry + lint + test + verify:features + buildAll four must pass, plus:
-
npm run verify:features -- <slug>reports OK - 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.