Conventions that aren't visible from the code itself. See CONTRIBUTING.md for architecture and the PR process.
npm run type-check is the quick way to validate a change — it checks types
without the dev server's interference, and it is the one to reach for rather
than npm run build, which is for shipping or for debugging the build itself.
npm run type-check:watch leaves it running.
The dev server, DynamoDB and the Ory tunnel belong to whoever is at the
keyboard, and are usually already up. Don't start or restart them, and don't run
npm run init-local — it reseeds the local database, and the fixtures it lays
down are not the state someone may be in the middle of debugging. When a local
lookup comes back empty, the cause is in the code far more often than in the
data.
When communicating about the system, use these terms:
- objects (not files) - the system is object storage based, as such data is stored as objects rather than files.
- requests (not downloads) - the system is built to work with cloud native formats meaning that clients will send many byte requests rather than download the entire object. As such, we shouldn't assume that a request downloads the entire file.
Stories are published at ui.source.coop. Run it locally
with npm run storybook.
Every UI change lands in Storybook in the same PR: a new component gets a
.stories.tsx, a changed one gets its stories updated, a removed one gets its
stories removed. A state that can only be reached by clicking through the running
app is a state nobody reviews.
Stories render in a browser bundle, so a component that fetches its own data
can't have one. ProductSummaryCard is an async server component whose first
act is await getPageSession() — cookies and a DynamoDB lookup, neither of which
exists in the browser — so no prop you pass can make it render.
Split such a component in two: the server half keeps the async, the session and
the permission checks, and passes plain values down; the presentational half is
an ordinary function of its props, knows nothing about sessions, and is the half
that gets a story. ProductDoi is that half of ProductSummaryCard — the card
calls <ProductDoi doi={...} />, and the story renders ProductDoi with any DOI
it likes.
A component that imports a server action needs sb.mock() in
.storybook/preview.tsx, or its story fails with __filename is not defined.
Show responsive behavior by pinning a viewport, never by wrapping a story in a fixed-width box. A box exactly as wide as the frame has none of the page padding the real layout has, so it invents overflow the page doesn't have.
The JSDoc above meta, and above each story export, is published prose on
ui.source.coop — not a code comment. Write it for a reader.
Wherever the UI names an account, show it with its card on hover:
DisplayNameLink or AvatarLinkCompact, or AccountInfoHoverCard around
anything else. A tooltip can't hold the card, so keep names out of tooltips.
Look up every account a page names in one fetchManyByIds call, pass the
browser its publicView, and show an id that doesn't resolve as plain text.
Two repositories outside this one hold prose that a change here can quietly invalidate. Check both while the change is still in your head — whoever finds a stale page a month from now has no idea which PR made it stale.
docs.source.coop is the
user-facing documentation. A change to a flow a user walks through — creating an
account or a product, uploading data, bringing your own bucket — dates the page
in docs/using-source/ that describes it, and a change to how the platform is
put together dates docs/about-source/.
data.source.coop is the
data proxy, and its adrs/ directory records the decisions this app shares with
it: authorization, STS credentials, API keys, outbound federation. An ADR records
a decision, not the code that implements it, so a change that merely implements
one leaves it alone. A change that moves the decision needs a new ADR, or an
amendment to the one it supersedes, in that repository.
public/llms.txt is the same documentation written for AI
agents, and it lives in this repository, so it ships in the same PR as the
change that dates it. A new feature an agent could use — a public endpoint, a
new way to read or publish data, a new docs page worth pointing at — gets a line
there, and a renamed docs slug, moved endpoint or removed feature gets its line
fixed. Every URL in the file should resolve in production without auth.
The PR description says which way it went: name the doc, ADR or llms.txt
line the change affects and link the PR carrying that update, or name the ones
you checked and say why they still hold. "Checked the data-proxy ADRs; 005 still
describes the model" is worth writing, because silence reads the same whether
the docs were checked or forgotten.
Read API.md before adding or changing an endpoint under /api/v1. It
covers the operations layer every route goes through, how a route documents
itself, the shapes of lists, errors and methods, and which consumers a change
has to stay compatible with.
Every error the API answers is an RFC 9457 problem, served as
application/problem+json and built from the table in
src/lib/api/problems.ts. Answer one through toResponse or
problemResponse, never a hand-built error body. API.md's "Errors" says why.
New functionality ships with its /api/v1 endpoint in the same PR, and an
issue that plans it names the endpoint. A feature only the UI can reach is one
that scripts, the CLI and agents can't use. The UI and the endpoint go through
one operation, so the two can't disagree about a rule — see API.md's "One
operation, thin adapters".
A PR that touches the UI links the stories it affects, on that branch's Storybook deploy:
https://<deploy-host>/?path=/story/<story-id>
Vercel caps a hostname label at 63 characters, so a long branch name is cut short
and given a hash (source-coop-ui-git-feat-github-immutable-re-82950d-radiantearth.vercel.app),
and a hostname pasted together from the full branch name doesn't resolve. The
deploy doesn't exist yet when the description is first written, so compute the
host from the branch:
b=$(git branch --show-current) p=source-coop-ui t=radiantearth
l="$p-git-$(printf %s "$b" | tr -c 'a-zA-Z0-9' - | tr A-Z a-z)-$t"
[ ${#l} -gt 63 ] && l="${l:0:$((55 - ${#t}))}-$(printf %s "git-$b$p" | shasum -a 256 | cut -c1-6)-$t"
echo "https://$l.vercel.app"Once the PR is open, the source-coop-ui row of the Vercel bot's comment
(gh pr view <n> --comments) has the host Vercel actually assigned; check the
links against it.
Link the specific stories rather than the root, and include a screenshot of anything visual — it shows the change in the review itself, and it outlives the branch deployment, which goes away with the branch. (Branch deployments ask for a Vercel login; ui.source.coop is public.)
The title and description describe the branch as it stands, not as it stood when the PR was opened. After pushing to a branch with an open PR, re-read both: if the approach has moved on, rewrite them; if they still hold, say so rather than leaving it unsaid. A description is read as the spec, so a stale one sends a reviewer looking for code that isn't there. Two parts rot first — a Testing section asserts checks that may predate the current commits, and a title has to keep earning its Conventional Commits type once the work changes shape.
Write PR bodies, issue bodies and commit message bodies as unwrapped paragraphs — one long line per paragraph, no hard line breaks. GitHub wraps them to the reader's window; hard breaks inserted at some other window's width wrap twice and read ragged. The commit subject line is the exception and stays short, because nothing wraps it. (This file and the rest of the repo's Markdown still wrap at 80.)
A comment says why the code is the way it is, in the present tense. What it used to do, what broke, and what a change fixed belong in the commit message and the PR description — that is where someone goes looking for history, and it stays accurate there as the code moves on. This matters twice over in story JSDoc, which is published.