diff --git a/README.md b/README.md
index 18bd6ff..8bacc01 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
The Next.js front end for the [OrcaHouse](https://github.com/umccr/orcahouse) data
warehouse. Its first module is a browser for the OrcaVault data mart: every table the
-mart GraphQL API exposes, grouped the way the dbt project lays them out, with filtering,
-sorting, paging and CSV export. Further modules are planned for project overview,
+mart GraphQL API exposes, grouped the way the dbt project lays them out, with search,
+filtering, sorting, paging and CSV export. Further modules are planned for project overview,
pipeline monitoring, automations, and user-built tables.
This is currently a demo MVP. It is built as static files served under
-https://portal.umccr.org/orcahouse/, next to the OrcaBus portal, and people sign in with their UMCCR
-Google account through the portal's Cognito user pool.
+, next to the OrcaBus portal, and people sign in with their
+UMCCR Google account through the portal's Cognito user pool.
## Related repositories
@@ -19,61 +19,11 @@ Google account through the portal's Cognito user pool.
| [umccr/orcahouse-doc](https://github.com/umccr/orcahouse-doc) | Warehouse documentation, glossary and ERDs |
| [umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines) | The portal's S3 buckets, CloudFront distribution, runtime config (`env.js`) and this app's build/deploy pipeline |
-## How it works
+## Quick start
-```text
-Deployed: https://portal.umccr.org/orcahouse/ (static files in S3, behind the portal's CloudFront)
- Browser ──/orcahouse/env.js────> the portal's runtime config: Cognito user pool and app client
- Browser ──GraphQL + ID token───> https://mart..umccr.org/graphql
- API Gateway JWT authorizer
- -> Lambda (PostGraphile)
- -> Aurora PostgreSQL `mart` schema
-
-Local development: http://localhost:3000/orcahouse/ (next dev)
- Browser ──/orcahouse/api/graphql/──> Next.js dev proxy ──MART_API_TOKEN──> https://mart.prod.umccr.org/graphql
-```
-
-- **Static export, one page.** `next build` writes plain HTML, CSS and JS to `out/` with
- `basePath: '/orcahouse'`, the counterpart of orca-ui-v2's `base: '/v2/'`. The app is a single
- page: the catalogue is `/orcahouse/` and a table is `/orcahouse/?table=`. One
- `index.html` therefore serves every URL, which is what the portal's CloudFront rewrite
- expects, and tables the build has never seen still open.
-- **No backend host is built in.** The portal builds this app once and promotes the same
- artifact from dev to prod, so `src/lib/environment.ts` derives the mart API host from the
- hostname the app is served from: `mart.dev.umccr.org` on the dev portal,
- `mart.prod.umccr.org` on prod. `MART_API_URL` is local development only.
-- **Cognito sign-in.** `src/lib/auth.ts` configures Amplify for the hosted UI with Google
- federation, as orca-ui-v2 does. Deployed, it reads the portal's `env.js` from this app's own base
- path, so it uses the portal's app client and shares the portal session: anyone signed in to
- the portal is signed in here. That client is also the only one the mart API's authorizer accepts. Locally the
- settings come from `start.sh`. `AuthGate` in the root layout shows the sign-in page to anyone
- signed out, and the avatar menu in the header shows the profile and current token, has a
- theme setting, and signs out.
-- **API calls.** Deployed, the browser calls the mart API directly with the ID token; the
- API's CORS policy allows the portal origins. It does not allow `localhost`, so in development
- requests go through `src/app/api/graphql/route.dev.ts`, a same-origin proxy that sends
- `MART_API_TOKEN`. The `.dev.ts` extension is only a page extension under `next dev`, which
- keeps the proxy out of the static export.
-- **Runtime introspection.** PostGraphile generates the API from the `mart` schema, so the
- UI introspects it on load and builds each table view from the live columns, filter
- operators and sort keys. Browsing a new mart table needs no code.
-- **Catalogue registry.** `src/lib/catalog.ts` maps dbt table names to groups, descriptions
- and stability. The groups mirror `orcavault/models/mart//` in the orcahouse repo,
- and the descriptions and STABLE/DEMO remarks come from its
- `seeds/dictionary/dictionary__data_mart_catalog.csv`. The live schema stays the source
- of truth: catalogued tables missing from the API are shown as unavailable, and API
- collections missing from the registry appear under "Other".
-- **Side navigation.** Groups fold, and the folds are remembered in the browser. The search box
- filters tables as you type: each word matches a table name fuzzily (`fqh` finds
- `fastq_history`), or appears in the group name or description.
-- **Shareable URLs.** The table, page, page size, sort and filter live in the query string, for
- example
- `/orcahouse/?table=lims&sort=SEQUENCING_RUN_DATE_DESC&filter={"and":[{"libraryId":{"equalTo":"L2400001"}}]}`.
-
-## Run locally
-
-Requires Node 24 (see `.nvmrc`), pnpm 10 (`corepack enable` picks the version from
-`package.json`) and the AWS CLI.
+Needs Node 24, pnpm 10, the AWS CLI and pre-commit. The
+[local development guide](docs/local-development.md) covers each step and what to do when one
+fails.
```sh
make install
@@ -81,72 +31,9 @@ aws sso login --profile dev && export AWS_PROFILE=dev
make start MART_API_TOKEN=
```
-`make start` sources `start.sh`, the same wrapper orca-ui-v2 uses: it reads the Cognito sign-in
-settings for the localhost app client from SSM Parameter Store in the dev account, exports them
-as `NEXT_PUBLIC_*` variables and starts the dev server. Open http://localhost:3000/orcahouse/
-(the root `/` redirects there) and sign in with your UMCCR Google account.
-
-The mart API is only deployed to prod, so the Makefile points `MART_API_URL` at
-https://mart.prod.umccr.org. It does not accept localhost sign-in tokens, so in local
-development the proxy sends your own token instead: copy the ID token from portal.umccr.org
-(profile menu > Token) and pass it as `MART_API_TOKEN`. Portal ID tokens last up to a day; when
-API calls fail with "The mart API rejected MART_API_TOKEN", restart with a fresh one. The static
-build has no proxy, so it never uses `MART_API_TOKEN`.
-
-The dev server must stay on port 3000, the only callback URL registered on the Cognito
-localhost app client. `make dev` starts it without the Cognito settings, so the sign-in page
-only reports that sign-in is not configured.
-
-| Variable | Set by | Purpose |
-| ------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------- |
-| `NEXT_PUBLIC_COGNITO_USER_POOL_ID` | `start.sh`, from `/data_portal/client/cog_user_pool_id` | Cognito user pool |
-| `NEXT_PUBLIC_COGNITO_OAUTH_DOMAIN` | `start.sh`, from `/data_portal/client/oauth_domain` | Hosted UI domain prefix (a full host name also works) |
-| `NEXT_PUBLIC_COGNITO_APP_CLIENT_ID` | `start.sh`, from `/data_portal/client/cog_app_client_id_local` | Localhost app client |
-| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_IN` | `start.sh`, from `/data_portal/client/oauth_redirect_in_local` | Unused: the app derives its own redirect (see below) |
-| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_OUT` | `start.sh`, from `/data_portal/client/oauth_redirect_out_local` | Unused: the app derives its own redirect (see below) |
-| `NEXT_PUBLIC_COGNITO_REGION` | `start.sh` (`ap-southeast-2`) | Region of the user pool |
-| `MART_API_URL` | Makefile, default `https://mart.prod.umccr.org` | Local development only: upstream for the dev proxy |
-| `MART_API_TOKEN` | you, e.g. `make start MART_API_TOKEN=...` | Local development only: bearer token for mart API calls |
-
-The deployed app does not use the `NEXT_PUBLIC_*` values: it reads the same settings from the
-portal's `env.js`. It does not use `MART_API_URL` either, deriving the mart host from its own
-hostname instead, so nothing environment-specific is baked into the artifact.
-
-The OAuth redirect URLs are derived rather than read from either source, in development and when
-deployed: both `env.js` and the SSM parameters name the portal root, which is a different app.
-`src/lib/auth.ts` uses the current origin plus the base path, so local sign-in returns to
-http://localhost:3000/orcahouse/ and the deployed app returns to its own page. Both URLs are
-registered on the relevant Cognito app client by the `cognito_aai` Terraform stack.
-
-## Deployment
+Open and sign in with your UMCCR Google account.
-Deployment is owned by
-[umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines),
-not by this repository. There is nothing to run here: a push to `main` triggers
-`OrcaHouseAppCICDPipeline`, which runs `pnpm build`, syncs `out/` to
-`s3://orcahouse-cloudfront-/orcahouse/`, and invokes the portal's config Lambda to write
-`env.js`. Dev deploys automatically; prod is behind a manual approval.
-
-That repository owns the bucket, the `/orcahouse/*` CloudFront behaviour and the viewer-request
-rewrite. This app's entry in its registry (`lib/portal/apps.ts`) declares
-`pathPrefix: 'orcahouse'` and `clientRouting: 'static-export'` — the latter is why
-`trailingSlash: true` matters here: it makes each route export as `/index.html`, which is
-what the rewrite resolves to.
-
-Two consequences of the shared portal worth knowing:
-
-- **Nothing environment-specific is in the artifact.** One build is promoted from dev to prod.
- Cognito settings arrive at runtime via `env.js`; the mart API host is derived from the
- hostname. Do not add a backend URL to `next.config.ts`'s `env` block, which Next inlines at
- build time.
-- **Sign-in returns to this app, not the portal home page.** The redirect URL is derived from the
- current origin plus the base path, so it is right in every environment without configuration.
- That exact URL must be registered as a callback and logout URL on the portal's Cognito app
- client, which the `cognito_aai` Terraform stack generates from its `portal_app_paths` list.
- **Apply that stack before shipping a change to the base path**, or the hosted UI rejects sign-in
- with `redirect_mismatch`.
-
-## Development workflow
+## Commands
| Command | What it does |
| ----------------- | ---------------------------------------------------------------- |
@@ -163,55 +50,33 @@ Two consequences of the shared portal worth knowing:
Pull requests run the same checks plus a production build through
`.github/workflows/pr-tests.yml`. Dependabot proposes weekly dependency and action updates.
-## Project layout
-
-```text
-src/
- app/
- layout.tsx app shell: theme script, auth gate, header, side navigation, Apollo provider
- page.tsx the one page
- api/graphql/route.dev.ts local-development proxy to the mart API (not in the build)
- components/
- MartView.tsx the catalogue, or a table when the URL has ?table=
- TableBrowser.tsx orchestrates schema, URL state, query and layout
- DataTable.tsx FilterBuilder.tsx Pagination.tsx ColumnPicker.tsx
- CatalogHome.tsx SideNav.tsx AppHeader.tsx
- AuthGate.tsx resolves the Cognito session; shows SignInPage when signed out
- UserMenu.tsx header avatar menu: ProfileDialog, TokenDialog, SettingsDialog
- hooks/
- useMartSchema.ts introspection query, cached by Apollo
- useTheme.ts stored light/dark/system preference
- useCollapsedGroups.ts side navigation folds, remembered in localStorage
- lib/
- auth.ts Amplify configuration (portal /env.js or start.sh), ID token, return path
- apollo.ts Apollo Client factory: the mart API directly, or the dev proxy
- catalog.ts table registry mirroring the dbt mart folders
- schema.ts introspection helpers: collections, columns, filters, sorts
- query-builder.ts builds the per-table rows query
- table-search.ts fuzzy table search for the side navigation
- filters.ts csv.ts theme.ts
-```
-
-## Adding or changing a table
-
-Nothing is required for a new mart table to appear: it shows up under "Other" as soon as
-the API exposes it. To place it in a group with a description, status and default sort,
-add an entry to `src/lib/catalog.ts`. The `collection` value is the PostGraphile root
-field, `all` + the plural PascalCase table name (`fastq_history` becomes `allFastqHistories`).
-
-## Known limits and next steps
-
-- **Authentication.** Sign-in happens in the browser and the tokens live in localStorage, shared
- with the portal on portal.umccr.org. The dev proxy forwards whatever bearer token it has and
- relies on the API's authorizer to check it.
-- **Authorization.** Any valid token can read every mart table today: the API runs with
- `ignoreRBAC` and one shared read-only database user. Showing different tables to
- different users must be enforced in the API or with Postgres roles, not in this UI.
-- **Catalogue drift.** Descriptions and STABLE/DEMO remarks are a copy of the dbt seed.
- Reading them live from the `catalog` mart table would remove the duplication.
-- **Query limits.** The API caps request bodies at 10,000 bytes and uses offset pagination,
- so very wide tables or very deep pages will be slow or rejected.
-- **No dev mart API.** `mart.dev.umccr.org` does not exist yet, so on the dev portal queries
- surface an API error while sign-in and the catalogue still work. Deliberate: better than the
- dev deployment reading production data. Local development points at prod with your own
- `MART_API_TOKEN`.
+## Key things to know
+
+- **One static page.** `next build` exports plain files served under `/orcahouse/`. A table is
+ `/orcahouse/?table=`, so one `index.html` serves every URL, and the table, page, sort,
+ search and filter all live in the query string for sharing.
+- **The API defines the tables.** The UI introspects the mart GraphQL API at runtime, so a new
+ mart table appears without code changes. `src/lib/catalog.ts` only adds groups, descriptions
+ and STABLE/DEMO status.
+- **Nothing environment-specific is in the build.** One artifact is promoted from dev to prod:
+ the Cognito settings come from the portal's `env.js` and the mart API host from the page's
+ hostname. Never add a backend URL to the `env` block in `next.config.ts`.
+- **The mart API is prod-only.** Local development reaches `mart.prod.umccr.org` through a dev
+ proxy with your own `MART_API_TOKEN`. The dev portal shows API errors until
+ `mart.dev.umccr.org` exists.
+- **Some features follow the API's configuration.** Row search needs the API (orcahouse
+ `infra/api`) to allow the `includesInsensitive` filter operator; where it does not, the search
+ box is hidden.
+- **Deploys happen elsewhere.** A push to `main` triggers the pipeline in
+ frontend-infrastructure-pipelines, and prod needs a manual approval. Changing the base path
+ needs the `cognito_aai` Terraform stack applied first.
+
+## Documentation
+
+| Document | What it covers |
+| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| [How it works](docs/how-it-works.md) | Request flow, sign-in, runtime schema introspection, and how search, filters, sorting and export work |
+| [Local development](docs/local-development.md) | Prerequisites, running the dev server, the mart API token, environment variables, troubleshooting |
+| [Deployment](docs/deployment.md) | The portal pipeline, runtime config, Cognito redirects, and the mart API this app depends on |
+| [Development guide](docs/development-guide.md) | Project layout, naming and code conventions, common changes, gotchas, known limits |
+| [OrcaVault project description](docs/OrcaVault-PD.md) | Background on the OrcaVault warehouse the mart tables come from |
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..c232715
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,96 @@
+# Deployment
+
+How OrcaHouse UI reaches , and what it depends on outside
+this repository.
+
+## The pipeline
+
+Deployment is owned by
+[umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines),
+not by this repository, so there is nothing to run here. A push to `main` triggers
+`OrcaHouseAppCICDPipeline`, which:
+
+1. runs `pnpm build`,
+2. syncs `out/` to `s3://orcahouse-cloudfront-/orcahouse/`,
+3. invokes the portal's config Lambda to write this app's `env.js`.
+
+Dev deploys automatically; prod is behind a manual approval.
+
+That repository also owns the bucket, the `/orcahouse/*` CloudFront behaviour and the
+viewer-request rewrite. This app's entry in its registry (`lib/portal/apps.ts`) declares
+`pathPrefix: 'orcahouse'` and `clientRouting: 'static-export'`. The latter is why
+`trailingSlash: true` matters here: it makes each route export as `/index.html`, which is
+what the rewrite resolves to.
+
+## One artifact for every environment
+
+One build is promoted from dev to prod, so nothing environment-specific may be in it:
+
+- **Cognito settings** arrive at runtime through `env.js`, which the app loads from its own base
+ path (`/orcahouse/env.js`).
+- **The mart API host** is derived from the hostname the page is served from
+ (`src/lib/environment.ts`; see [How it works](how-it-works.md#which-api-the-app-calls)).
+
+Do not add a backend URL or any other per-environment value to the `env` block in
+`next.config.ts`: Next inlines those values into the bundle at build time, so the dev deployment
+would carry them into prod. `MART_API_URL` is deliberately left out of it and is only read by the
+local dev proxy.
+
+## Sign-in redirects
+
+Sign-in returns to this app, not the portal home page. The redirect URL is the current origin
+plus the base path (`https://portal.umccr.org/orcahouse/` in prod), so it is right in every
+environment without configuration.
+
+That exact URL must be registered as a callback and logout URL on the portal's Cognito app
+client. The `cognito_aai` Terraform stack generates those URLs from its `portal_app_paths` list.
+The same stack registers on the localhost app client.
+
+### Changing the base path
+
+The base path (`/orcahouse`) is set in several places that must agree:
+
+1. `BASE_PATH` in `next.config.ts`.
+2. `pathPrefix` in the app registry, `lib/portal/apps.ts` in frontend-infrastructure-pipelines.
+3. `portal_app_paths` in the `cognito_aai` Terraform stack.
+
+**Apply the `cognito_aai` stack before shipping the change**, or the hosted UI rejects sign-in
+with `redirect_mismatch`.
+
+## The mart API
+
+The mart GraphQL API is deployed separately, from the orcahouse repository's `infra/api`
+Terraform module, and only to prod (). See that module's README for
+the full steps. In short, with prod AWS credentials:
+
+```sh
+cd infra/api/lambda-server
+pnpm install --frozen-lockfile
+pnpm build # bundles and zips dist/index.zip
+
+cd ..
+terraform init
+terraform workspace select prod
+terraform plan -var-file="orcavault.tfvars" # expect only the Lambda function to change
+terraform apply -var-file="orcavault.tfvars"
+```
+
+This UI depends on how that API is configured:
+
+| API setting | What depends on it |
+| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| JWT authorizer audience: the portal's Cognito app client | Deployed sign-in tokens are accepted; localhost sign-in tokens are not, hence `MART_API_TOKEN` |
+| CORS allowed origins (`portal.umccr.org`, `orcaui.umccr.org`, their `prod` aliases) | The browser can call the API directly. Serving this app from a new hostname needs that origin added |
+| `connectionFilterAllowedOperators` | The filter builder's operators. Row search needs `includesInsensitive`; without it the search box is hidden |
+| `maxRequestLength` (10,000 bytes) | How many columns one rows query can request |
+
+The UI reads the operators from the live schema, so an API change and a UI change can ship in
+either order: the UI hides what the API does not offer yet, and picks up a newly allowed operator
+on the next page reload.
+
+## No dev mart API
+
+`mart.dev.umccr.org` does not exist yet, so on the dev portal queries show an API error while
+sign-in and the catalogue still work. This is deliberate: it is better than the dev deployment
+reading production data. Local development points at prod with your own `MART_API_TOKEN` (see
+[Local development](local-development.md#the-mart-api-token)).
diff --git a/docs/development-guide.md b/docs/development-guide.md
new file mode 100644
index 0000000..9e43d9a
--- /dev/null
+++ b/docs/development-guide.md
@@ -0,0 +1,183 @@
+# Development guide
+
+Where the code lives, the conventions it follows, how to make common changes, and what to watch
+out for. For the commands, see the [README](../README.md#commands); for the runtime behaviour, see
+[How it works](how-it-works.md).
+
+## Project layout
+
+```text
+Makefile install, check, start/dev, build; MART_API_URL and MART_API_TOKEN for the dev proxy
+start.sh local development: Cognito settings from SSM, then next dev on port 3000
+next.config.ts base path, static export, dev-only proxy route
+docs/ this documentation
+src/
+ app/
+ layout.tsx app shell: theme script, auth gate, header, side navigation, Apollo provider
+ page.tsx the one page
+ globals.css Tailwind setup, colour tokens, dark mode variant
+ api/graphql/route.dev.ts local-development proxy to the mart API (not in the build)
+ components/
+ MartView.tsx the catalogue, or a table when the URL has ?table=
+ CatalogHome.tsx home page: tables by group, with how many are live
+ TableBrowser.tsx one table: schema metadata, URL state, rows query and layout
+ RowSearch.tsx search box: a contains search on one text column
+ FilterBuilder.tsx advanced filter: conditions combined with AND or OR
+ DataTable.tsx the rows, sortable headers and cell formatting
+ Pagination.tsx page and page-size controls
+ ColumnPicker.tsx show and hide columns
+ SideNav.tsx table tree with fuzzy search and foldable groups
+ AuthGate.tsx resolves the Cognito session; shows SignInPage when signed out
+ AppHeader.tsx UserMenu.tsx header and avatar menu: ProfileDialog, TokenDialog, SettingsDialog
+ ApolloWrapper.tsx Apollo provider
+ ThemeSync.tsx keeps in step with the stored preference
+ Dialog.tsx ErrorNotice.tsx StatusBadge.tsx
+ ui.ts shared Tailwind class strings: BUTTON, BUTTON_PRIMARY, INPUT, MENU
+ hooks/
+ useMartSchema.ts introspection query, cached by Apollo
+ useTheme.ts stored light/dark/system preference
+ useCollapsedGroups.ts side navigation folds, remembered in localStorage
+ useIsClient.ts useNow.ts client-only rendering; a ticking clock for token expiry
+ lib/
+ auth.ts Amplify configuration (portal /env.js or start.sh), ID token, return path
+ apollo.ts Apollo Client factory: the mart API directly, or the dev proxy
+ environment.ts deployed environment and mart API host from the hostname
+ catalog.ts table registry mirroring the dbt mart folders
+ schema.ts introspection helpers: collections, columns, filters, sorts
+ query-builder.ts builds the per-table rows query
+ filters.ts advanced filter state, its URL JSON, combining filters
+ row-search.ts the row search: searchable columns and its filter clause
+ table-search.ts fuzzy table search for the side navigation
+ csv.ts theme.ts
+```
+
+## Conventions
+
+### Files
+
+| Kind | Convention | Example |
+| ---------------- | --------------------------------------------------------------- | ----------------------------------- |
+| React components | PascalCase `.tsx`, exporting one component named after the file | `RowSearch.tsx` exports `RowSearch` |
+| Hooks | `use` + PascalCase, `.ts`, in `src/hooks/` | `useMartSchema.ts` |
+| Library modules | Lowercase kebab-case `.ts` in `src/lib/`, no React | `row-search.ts`, `query-builder.ts` |
+| Dev-only routes | `.dev.ts`, which only `next dev` treats as a page extension | `src/app/api/graphql/route.dev.ts` |
+| Documentation | Lowercase kebab-case `.md` in `docs/` | `local-development.md` |
+
+### Code
+
+- **Exports.** Named exports throughout. Default exports only where Next.js requires them
+ (`page.tsx`, `layout.tsx`).
+- **Names.** Types and interfaces in PascalCase, module-level constants in UPPER_SNAKE_CASE
+ (`PAGE_SIZES`, `OPERATOR_ORDER`, `BUTTON`), everything else in camelCase.
+- **Imports.** The `@/` alias for anything under `src/` (`@/lib/schema`), and `./` for siblings
+ in the same folder (`./ui`).
+- **Where logic goes.** Data logic that does not need React, such as parsing the schema,
+ building queries and filters, and matching searches, lives in `src/lib/` as plain functions.
+ Components hold state and layout.
+- **Comments** explain why, not what. Exported functions and components get a short doc comment.
+- **Spelling.** British English in comments, docs and UI text ("catalogue", "colour").
+- **Formatting.** Prettier: single quotes (JSX too), semicolons, 100-column lines, ES5 trailing
+ commas, and Tailwind classes sorted by `prettier-plugin-tailwindcss`. EditorConfig: UTF-8, LF,
+ two-space indents, tabs in the Makefile. Run `pnpm format` rather than formatting by hand.
+
+### Styling
+
+- **Tailwind CSS v4**, configured in `src/app/globals.css`, and no component library.
+- **Colour tokens** come in light and dark pairs: `signal`, `canvas`, `surface`, `line`, `muted`
+ and `ink`, each with a `-dark` twin, plus `raised-dark`. Use them in pairs, for example
+ `text-muted dark:text-muted-dark`.
+- **Dark mode** is the `dark:` variant, which follows `` rather than the
+ system media query, so the user's setting wins.
+- **Controls** reuse the class strings in `src/components/ui.ts` (`BUTTON`, `BUTTON_PRIMARY`,
+ `INPUT`, `MENU`), so buttons and inputs line up at the same height.
+- **Icons** come from `lucide-react`, with `aria-hidden='true'` when they are decorative.
+
+### Names shared with the API, the URL and the browser
+
+| Thing | Convention | Example |
+| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- |
+| Catalogue `table` | The dbt model name, in snake_case; also the `?table=` value | `fastq_history` |
+| Root field (`collection`) | `all` + the plural PascalCase table name | `allFastqHistories` |
+| GraphQL types | Singular node, `Filter`, `OrderBy` | `FastqHistory`, `FastqHistoryFilter`, `FastqHistoriesOrderBy` |
+| Columns | camelCase of the snake_case database column | `sequencing_run_id` becomes `sequencingRunId` |
+| Sort values | The column in UPPER_SNAKE_CASE plus `_ASC` or `_DESC` | `SEQUENCING_RUN_DATE_DESC` |
+| Column headers | `humanize()`, keeping the acronyms listed in `ACRONYMS` | "Sequencing Run ID" |
+| URL parameters | Short lowercase words | `table`, `page`, `size`, `sort`, `filter`, `q`, `qcol` |
+| Browser storage keys | Prefixed `orcahouse-ui.` | `orcahouse-ui.theme`, `orcahouse-ui.sidenav-collapsed` |
+
+## Common changes
+
+### Adding or changing a table
+
+Nothing is required for a new mart table to appear: it shows up under "Other" as soon as the API
+exposes it. To place it in a group with a description, status and default sort, add an entry to
+`src/lib/catalog.ts`:
+
+- `table`: the dbt model name.
+- `collection`: the PostGraphile root field, `all` + the plural PascalCase table name
+ (`fastq_history` becomes `allFastqHistories`).
+- `description` and `status`: from `orcavault/seeds/dictionary/dictionary__data_mart_catalog.csv`
+ in the orcahouse repo.
+- `defaultSort` (optional): an orderBy value such as `SEQUENCING_RUN_DATE_DESC`. Without it the
+ table sorts by its first date or datetime column, newest first, if it has one.
+
+### Adding a filter operator
+
+1. Allow it in the API: `connectionFilterAllowedOperators` in the orcahouse repo's
+ `infra/api/lambda-server/src/postgraphile.ts`, then deploy the API (see
+ [Deployment](deployment.md#the-mart-api)).
+2. Add it to `OPERATOR_ORDER` and `OPERATOR_LABELS` in `src/lib/schema.ts`. The filter builder
+ only offers operators it knows, in that order, and the first one is the default for a new
+ condition.
+3. If its value is not a plain string, number, date or boolean, handle it in `coerceValue`
+ (`src/lib/filters.ts`) and `ValueInput` (`FilterBuilder.tsx`).
+
+### Adding a URL parameter
+
+Read it with `params.get()` in `TableBrowser` and write it through `update(patch, replace)`.
+Leave the default value out of the URL, reset `page` when the change alters which rows match, and
+pass `replace = true` for changes that should not add a history entry, such as typing.
+
+## Gotchas
+
+- **Nothing environment-specific in the build.** Never add a backend URL to the `env` block in
+ `next.config.ts`; Next inlines it at build time and one artifact serves dev and prod.
+- **Base path changes need Cognito first.** Apply the `cognito_aai` stack before shipping, or
+ sign-in fails with `redirect_mismatch` (see [Deployment](deployment.md#changing-the-base-path)).
+- **Port 3000 locally.** It is the only callback registered on the Cognito localhost app client.
+- **`.dev.ts` files are not deployed.** Anything the static site needs cannot live in one.
+- **`useSearchParams` needs a Suspense boundary** for the static export to build. `page.tsx`
+ wraps `MartView`, and the layout wraps `SideNav`; keep new users of it inside one.
+- **Never send an empty filter.** The API rejects `{}`. Build filters with `combineFilters`,
+ which drops empty parts.
+- **Requests are capped at 10,000 bytes.** The rows query requests every column, which is fine
+ for today's tables but not for a table with hundreds of columns.
+- **The schema is read once per page load.** After an API change, reload to see it.
+- **The theme exists twice.** `THEME_SCRIPT` in `src/lib/theme.ts` runs before React; keep it in
+ step with `applyTheme` in the same file.
+- **Never commit tokens.** Pass `MART_API_TOKEN` on the command line rather than editing the
+ Makefile. After an intentional change that trips detect-secrets, run `make baseline`.
+- **Markdown-only pull requests skip CI** (`paths-ignore` in `.github/workflows/pr-tests.yml`),
+ so run `make check` locally before pushing docs.
+
+## Known limits and next steps
+
+- **Authentication.** Sign-in happens in the browser and the tokens live in `localStorage`,
+ shared with the portal on portal.umccr.org. The dev proxy forwards whatever bearer token it has
+ and relies on the API's authorizer to check it.
+- **Authorization.** Any valid token can read every mart table today: the API runs with
+ `ignoreRBAC` and one shared read-only database user. Showing different tables to different
+ users must be enforced in the API or with Postgres roles, not in this UI.
+- **Catalogue drift.** Descriptions and STABLE/DEMO remarks are a copy of the dbt seed. Reading
+ them live from the `catalog` mart table would remove the duplication.
+- **Query limits.** The API caps request bodies at 10,000 bytes and uses offset pagination, so
+ very wide tables or very deep pages will be slow or rejected.
+- **Search cost.** A contains search (`ILIKE '%text%'`) cannot use the tables' btree indexes, so
+ it reads the whole table. That is fine at today's sizes, up to about 400,000 rows in
+ `fastq_history`. If it gets slow, add `pg_trgm` GIN indexes on the most searched columns in the
+ dbt models.
+- **No dev mart API.** `mart.dev.umccr.org` does not exist yet, so on the dev portal queries show
+ an API error while sign-in and the catalogue still work (see
+ [Deployment](deployment.md#no-dev-mart-api)).
+- **CSV export is one page.** Export CSV downloads the rows on screen, at most 100. Exporting a
+ whole filtered table would need paging through the API or a server-side export.
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..6518621
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,285 @@
+# How it works
+
+How OrcaHouse UI is served, how it signs people in and reaches the mart API, and how the table
+view turns URL state into GraphQL queries. To run it, see [Local development](local-development.md);
+for where the code lives, see the [Development guide](development-guide.md).
+
+## Overview
+
+```text
+Deployed: https://portal.umccr.org/orcahouse/ (static files in S3, behind the portal's CloudFront)
+ Browser ──/orcahouse/env.js────> the portal's runtime config: Cognito user pool and app client
+ Browser ──GraphQL + ID token───> https://mart..umccr.org/graphql
+ API Gateway JWT authorizer
+ -> Lambda (PostGraphile)
+ -> Aurora PostgreSQL `mart` schema
+
+Local development: http://localhost:3000/orcahouse/ (next dev)
+ Browser ──/orcahouse/api/graphql/──> Next.js dev proxy ──MART_API_TOKEN──> https://mart.prod.umccr.org/graphql
+```
+
+The app has no server of its own. It is a static Next.js export that runs in the browser: it
+signs the user in with Cognito, then calls the mart GraphQL API with their ID token.
+
+## What happens on a page load
+
+1. **Theme.** An inline script in `` (`src/lib/theme.ts`) sets `data-theme` on ``
+ from the stored preference before the first paint, so the page never flashes the wrong theme.
+2. **Sign-in check.** `AuthGate`, in the root layout, shows "Checking sign-in…" while it
+ configures Amplify (see [Sign-in](#sign-in)). If the URL carries an OAuth `code` and `state`,
+ Amplify completes that redirect first.
+3. **Signed out.** The sign-in page renders at whatever URL was opened. Signing in stores that
+ path and query in `sessionStorage`, goes to the Cognito hosted UI (Google), and comes back to
+ `/orcahouse/`, where `AuthGate` restores the stored path.
+4. **Signed in.** The header, side navigation and Apollo provider render, and `useMartSchema`
+ sends one introspection query. Apollo caches the result for the life of the page, so every
+ component reads the same schema.
+5. **The view.** `MartView` reads `?table=`. Without it, the catalogue home page shows; with it,
+ `TableBrowser` renders that table. `TableBrowser` is keyed by the table name, so switching
+ tables starts with fresh column, search and filter state.
+
+## One static page
+
+- `next build` writes plain HTML, CSS and JS to `out/` (`output: 'export'`) with
+ `basePath: '/orcahouse'`, the counterpart of orca-ui-v2's `base: '/v2/'`.
+- There is one route. The catalogue is `/orcahouse/` and a table is `/orcahouse/?table=`,
+ so one `index.html` serves every URL. That is what the portal's CloudFront rewrite expects,
+ and it means tables the build has never seen still open.
+- `trailingSlash: true` makes each route export as `/index.html`, which is what the
+ rewrite resolves to (see [Deployment](deployment.md)).
+- `next dev` keeps its server features for the local API proxy. `.dev.ts` is a page extension
+ in development only, so `src/app/api/graphql/route.dev.ts` never reaches the export.
+
+## Which API the app calls
+
+The portal builds this app once and promotes the same artifact from dev to prod, so no API host
+is built in. `src/lib/environment.ts` derives it from the hostname the page is served from:
+
+| Hostname | Environment | Mart API |
+| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------- |
+| `portal.umccr.org`, `portal.prod.umccr.org`, `orcaui.umccr.org`, `orcaui.prod.umccr.org` | prod | `https://mart.prod.umccr.org` |
+| `portal.stg.umccr.org`, `orcaui.stg.umccr.org` | stg | `https://mart.stg.umccr.org` |
+| Anything else | dev | `https://mart.dev.umccr.org` |
+
+An unrecognised hostname resolves to dev, so a mistake fails towards the non-production API.
+Only the prod API exists today, so the dev portal shows an API error. That is deliberate: it is
+better than the dev deployment reading production data. Under `next dev` the app calls the local
+proxy instead, and `MART_API_URL` decides where the proxy forwards.
+
+## Sign-in
+
+`src/lib/auth.ts` configures Amplify for the Cognito hosted UI with Google federation, as
+orca-ui-v2 does, using the authorization code flow and the `openid email profile` scopes.
+
+- **Settings.** Deployed, they come from the portal's runtime config. The app loads
+ `/orcahouse/env.js`, which the portal's config Lambda writes into this app's own path and which
+ sets `window.config` (`VITE_COG_USER_POOL_ID`, `VITE_COG_APP_CLIENT_ID`, `VITE_OAUTH_DOMAIN`,
+ `VITE_REGION`). In development they come from the `NEXT_PUBLIC_COGNITO_*` variables that
+ `start.sh` exports. With neither, the sign-in page reports that sign-in is not configured.
+- **Shared session.** Deployed, the app uses the portal's app client, so anyone signed in to the
+ portal on portal.umccr.org is already signed in here. That client is also the only one the
+ mart API's authorizer accepts.
+- **Redirects.** Cognito returns to `/orcahouse/`. The app derives this at
+ runtime instead of reading it from `env.js` or SSM, which both name the portal root, a
+ different app. That exact URL must be registered on the Cognito app client (see
+ [Deployment](deployment.md#sign-in-redirects)).
+- **Tokens.** Amplify keeps the tokens in `localStorage` and refreshes the ID token when it has
+ expired. A failed refresh or a sign-out returns the app to the sign-in page.
+- **User menu.** The avatar menu shows the ID token's claims (Profile), a freshly refreshed ID
+ token and its expiry for GraphiQL or scripts (Token) and the theme setting (Settings), and it
+ signs out through the Cognito logout endpoint.
+
+## Calling the mart API
+
+- **Client.** `src/lib/apollo.ts` builds the Apollo Client. Its `HttpLink` resolves the endpoint
+ on each request and adds `Authorization: Bearer ` from Amplify.
+- **Deployed**, the browser calls `https://mart..umccr.org/graphql` directly. The API's CORS
+ policy allows the portal origins.
+- **In development**, that CORS policy does not allow `localhost`, so requests go to
+ `/orcahouse/api/graphql/`, a same-origin proxy (`route.dev.ts`). It forwards the request body
+ to `MART_API_URL` with `MART_API_TOKEN`, or with the session's token when that is not set, which
+ the API rejects because localhost sign-in uses a different app client. A 401 or 403 comes back
+ as a GraphQL error with a readable message, so the UI shows it instead of a network failure.
+- **The API** lives in the orcahouse repo under `infra/api`. It is an API Gateway JWT authorizer
+ in front of a Lambda that runs PostGraphile v5 over the `mart` schema as a read-only database
+ user. It accepts GraphQL over POST only and caps request bodies at 10,000 bytes. Filtering
+ comes from the connection-filter plugin, limited to an allowlist: `equalTo`, `notEqualTo`,
+ `lessThan`, `lessThanOrEqualTo`, `greaterThan`, `greaterThanOrEqualTo`, `isNull` and
+ `includesInsensitive`, combined with `and`, `or` and `not`. It rejects empty filter objects.
+
+## Reading the schema at runtime
+
+PostGraphile generates the API from the `mart` Postgres schema, so tables, columns, filter
+operators and sort keys change whenever dbt publishes a model. Instead of generated types, the UI
+introspects the live schema on load (`src/lib/schema.ts`) and derives:
+
+| Metadata | Derived from | Example for `lims` |
+| ------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| Collections | Root `Query` fields whose type has a `nodes` field, with the types of their `filter` and `orderBy` arguments | `allLims`: nodes `Lim`, filter `LimFilter`, sort `LimsOrderBy` |
+| Columns | Fields of the node type that take no arguments and return a scalar or enum | `libraryId: String`, `sequencingRunDate: Date` |
+| Filter fields | Fields of the filter input type other than `and`, `or` and `not`, keeping the operators the UI knows (`OPERATOR_ORDER`) | `libraryId`: equals, not equal, contains, greater than, …, is null |
+| Sort keys | Values of the orderBy enum | `SEQUENCING_RUN_DATE_DESC` |
+
+Browsing a new mart table therefore needs no code. The schema is read once per page load, so a
+change to the API, such as a newly allowed operator, shows after a reload.
+
+## The catalogue
+
+`src/lib/catalog.ts` lists the dbt mart tables with their group, description, STABLE/DEMO status
+and an optional default sort. `resolveCatalog` merges that list with the live collections:
+
+- An entry matches a collection by its `collection` value, or by its table name with the
+ pluralisation PostGraphile may apply (`all` + the table name, plus `s` or `es`).
+- Catalogued tables the API does not expose stay listed but greyed out, and open to a
+ "Not exposed by the API" notice.
+- Collections the catalogue does not list appear under "Other", so nothing the API exposes is
+ hidden.
+
+The home page lists every group and how many catalogued tables are live.
+
+## The table view
+
+`src/components/TableBrowser.tsx` connects the schema metadata, the URL and the rows query.
+
+### URL state
+
+Everything that decides which rows show lives in the query string, so any view can be shared or
+bookmarked. Default values are left out of the URL.
+
+| Parameter | Meaning | Default |
+| --------- | ---------------------------------------------------------- | ------------------------------------------ |
+| `table` | dbt table name | None: the catalogue home page |
+| `page` | Page number, from 1 | `1` |
+| `size` | Rows per page: 10, 25, 50 or 100 | `25` |
+| `sort` | One orderBy enum value, such as `SEQUENCING_RUN_DATE_DESC` | Set on the first visit (see below) |
+| `filter` | The advanced filter, as GraphQL filter JSON | Set on the first visit (see below) |
+| `q` | Row search text | None |
+| `qcol` | Row search column | `libraryId`, or else the first text column |
+
+For example,
+`/orcahouse/?table=lims&sort=SEQUENCING_RUN_DATE_DESC&filter={"and":[{"libraryId":{"equalTo":"L2400001"}}]}`
+or `/orcahouse/?table=workflow&q=umccrise&qcol=workflowName`.
+
+Changing the page, page size, sort or filter adds a browser history entry. Search changes
+replace the current entry, so typing does not leave one entry per pause.
+
+### First visit
+
+When the URL has neither `sort` nor `filter`, the view sorts newest first and hides rows whose
+sort column is null, as the legacy portal did. The sort is the catalogue's `defaultSort` when the
+API offers it, or else the first `Date` or `Datetime` column, descending. The filter is
+`{"and":[{"":{"isNull":false}}]}`. Both are written to the URL, replacing the current
+entry, so they show in the advanced filter and can be removed there.
+
+### The rows query
+
+`src/lib/query-builder.ts` builds one query per table. For `lims` it looks like this:
+
+```graphql
+query allLimsRows($first: Int!, $offset: Int!, $orderBy: [LimsOrderBy!], $filter: LimFilter) {
+ rows: allLims(first: $first, offset: $offset, orderBy: $orderBy, filter: $filter) {
+ totalCount
+ nodes {
+ sequencingRunId
+ sequencingRunDate
+ libraryId
+ # …every scalar column
+ }
+ }
+}
+```
+
+- The connection is aliased to `rows`, so every table shares one result shape.
+- Every scalar column is requested whichever ones are shown, so hiding or showing a column never
+ refetches. A few dozen column names stay well under the API's 10,000-byte request limit.
+- Paging is offset-based: `first` is the page size and `offset` is `(page - 1) × size`.
+ `totalCount` gives the number of pages.
+- While a new page loads, the previous rows stay on screen, dimmed.
+
+### Row search
+
+The search box above the table finds rows whose value in one text column contains the typed
+text, ignoring case. The logic is in `src/lib/row-search.ts` and the box is
+`src/components/RowSearch.tsx`.
+
+- **Operator.** The search sends the connection-filter operator `includesInsensitive`, which
+ PostGraphile runs as `"column" ILIKE '%text%'`. Any `%` or `_` in the text is escaped, so the
+ text always matches literally.
+- **Columns.** Only text columns support that operator, so only text columns are offered. The
+ search starts on `libraryId` when the table has one, since most mart tables are keyed by
+ library, and otherwise on the first text column.
+- **When it runs.** 400 ms after typing pauses, or straight away on Enter. Escape or the clear
+ button removes the search. Changing the text, or the column while there is text, goes back to
+ page 1.
+- **URL.** The text is stored as `q` and the column as `qcol`. The box keeps what the user is
+ typing while its own URL updates arrive, and it takes the URL's text when the URL changes for
+ any other reason, such as the back button.
+- **Availability.** If the API does not allow `includesInsensitive`, no column offers it and the
+ box is hidden. The advanced filter button still shows.
+
+### Advanced filter
+
+The **Advanced filter** button after the search box opens the filter builder
+(`src/components/FilterBuilder.tsx`), for conditions the search cannot express.
+
+- **Closed by default.** The button shows how many conditions are applied, including the
+ first-visit filter, so rows hidden by a filter are never a surprise. Closing the panel keeps
+ edits that have not been applied yet.
+- **Conditions.** Each condition is `column operator value`, and all of them combine with AND or
+ all with OR.
+- **Operators.** Every filterable column offers equals, not equal, greater than, greater or equal,
+ less than, less or equal, and is null / is not null. Text columns also offer contains
+ (`includesInsensitive`).
+- **Values.** The input follows the column type: a date picker for `Date` and `Datetime`
+ (compared in UTC), a number input for numbers, true or false for booleans, and text otherwise.
+ Values are converted to the type the API expects before they are sent.
+- **Apply and Reset.** Nothing is sent until Apply. Reset removes every condition.
+- **URL.** The applied filter is stored in `filter` as the ready-to-send GraphQL filter JSON, the
+ same convention the legacy portal used.
+
+### How search and filter combine
+
+The search is kept apart from `filter` and ANDed onto it when the query is sent
+(`combineFilters` in `src/lib/filters.ts`). With the first-visit filter and a search for `l24` on
+`lims`, the query's `filter` variable is:
+
+```json
+{
+ "and": [
+ { "and": [{ "sequencingRunDate": { "isNull": false } }] },
+ { "libraryId": { "includesInsensitive": "l24" } }
+ ]
+}
+```
+
+Missing and empty parts are left out, because the API rejects an empty filter object.
+
+### Sorting, columns, export and cells
+
+- **Sorting.** Column headers with an orderBy enum value are clickable. The first click sorts
+ descending and the next ascending. One column sorts at a time, and a new sort goes back to
+ page 1.
+- **Columns.** The Columns menu hides columns in the browser only. The choice is not in the URL
+ and resets when you switch tables.
+- **Export CSV.** Downloads the rows on the current page, visible columns only, as RFC 4180 CSV
+ with a byte order mark so that Excel reads it as UTF-8. The file is named
+ `
-page-.csv`.
+- **Cells.** Datetimes show without the `T` and fractional seconds, integers use thousands
+ separators, JSON values show as text, and nulls show as a faint italic `null`. Headers are
+ humanised from the field name, keeping acronyms: `sequencingRunId` becomes "Sequencing Run ID".
+
+## Side navigation
+
+The side navigation lists the catalogue by group. It is hidden on narrow screens.
+
+- **Table search.** Filters the tables as you type (`src/lib/table-search.ts`). Every word must
+ match, either fuzzily against the table name (`fqh` finds `fastq_history`, with the matched
+ letters highlighted) or, from three characters, within the group name or description. Name
+ matches rank first.
+- **Folds.** Groups fold. The folds are remembered in `localStorage` and follow other tabs.
+
+## Theme
+
+Light, dark or system, stored in `localStorage`. The head script applies it before the first
+paint, and `ThemeSync` keeps it in step with system changes and other tabs. Dark mode is the
+`data-theme` attribute on ``, which Tailwind's `dark:` variant follows.
diff --git a/docs/local-development.md b/docs/local-development.md
new file mode 100644
index 0000000..956abb0
--- /dev/null
+++ b/docs/local-development.md
@@ -0,0 +1,120 @@
+# Local development
+
+How to run OrcaHouse UI on your machine against the mart API. For how the pieces fit together,
+see [How it works](how-it-works.md).
+
+## Prerequisites
+
+| Tool | Version | Used for |
+| -------------- | ----------------------------------------------------------- | --------------------------------------------- |
+| Node.js | 24 (see `.nvmrc`) | Next.js |
+| pnpm | 10; `corepack enable` picks the version from `package.json` | Dependencies and scripts |
+| AWS CLI | v2, with SSO access to the dev account | Reading the Cognito sign-in settings from SSM |
+| pre-commit | Any recent version (`brew install pre-commit`) | `make install` and `make check` |
+| detect-secrets | Optional (`brew install detect-secrets`) | `make baseline` only |
+
+You also need a UMCCR Google account to sign in, and access to to
+copy a token for the mart API.
+
+## Start the dev server
+
+```sh
+make install # pre-commit hooks and pinned dependencies
+aws sso login --profile dev && export AWS_PROFILE=dev
+make start MART_API_TOKEN=
+```
+
+Then open (the root `/` redirects there) and sign in with your
+UMCCR Google account.
+
+`make start` sources `start.sh`, the same wrapper orca-ui-v2 uses. It reads the Cognito sign-in
+settings for the localhost app client from SSM Parameter Store in the dev account, exports them
+as `NEXT_PUBLIC_*` variables and starts `next dev` on port 3000.
+
+## The mart API token
+
+The mart API is only deployed to prod, so the Makefile points `MART_API_URL` at
+. Its authorizer only accepts tokens from the portal's app client,
+not the localhost app client you sign in with here. In development the dev proxy therefore
+sends your own token instead:
+
+1. Sign in to .
+2. Open the profile menu, choose **Token**, and copy the ID token.
+3. Pass it as `MART_API_TOKEN`, either on the command line as above or by exporting it once per
+ shell: `export MART_API_TOKEN=eyJ...` and then `make start`.
+
+Portal ID tokens last up to a day. When API calls fail with "The mart API rejected
+MART_API_TOKEN", restart with a fresh one.
+
+Do not paste the token into the Makefile's `MART_API_TOKEN ?=` line: the next `git add` would
+commit it. The detect-secrets hook is there to catch that, but the command line or a shell
+export keeps it out of the repository entirely.
+
+The static build has no proxy, so it never uses `MART_API_TOKEN`.
+
+## Port and sign-in
+
+The dev server must stay on port 3000, the only callback URL registered on the Cognito localhost
+app client. `PORT` still overrides it (`PORT=3001 make start`), but sign-in then fails.
+
+`make dev` starts the server without the Cognito settings, so the sign-in page only reports that
+sign-in is not configured. It is useful for work on the sign-in page itself.
+
+The OAuth redirect URLs are derived rather than read from SSM, because the SSM parameters name
+the portal root, a different app. `src/lib/auth.ts` uses the current origin plus the base path,
+so local sign-in returns to . That URL is registered on the
+localhost app client by the `cognito_aai` Terraform stack.
+
+## Environment variables
+
+| Variable | Set by | Purpose |
+| ------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------- |
+| `NEXT_PUBLIC_COGNITO_USER_POOL_ID` | `start.sh`, from `/data_portal/client/cog_user_pool_id` | Cognito user pool |
+| `NEXT_PUBLIC_COGNITO_OAUTH_DOMAIN` | `start.sh`, from `/data_portal/client/oauth_domain` | Hosted UI domain prefix (a full host name also works) |
+| `NEXT_PUBLIC_COGNITO_APP_CLIENT_ID` | `start.sh`, from `/data_portal/client/cog_app_client_id_local` | Localhost app client |
+| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_IN` | `start.sh`, from `/data_portal/client/oauth_redirect_in_local` | Unused: the app derives its own redirect |
+| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_OUT` | `start.sh`, from `/data_portal/client/oauth_redirect_out_local` | Unused: the app derives its own redirect |
+| `NEXT_PUBLIC_COGNITO_REGION` | `start.sh` (`ap-southeast-2`) | Region of the user pool |
+| `MART_API_URL` | Makefile, default `https://mart.prod.umccr.org` | Local development only: upstream for the dev proxy |
+| `MART_API_TOKEN` | You, for example `make start MART_API_TOKEN=...` | Local development only: bearer token for mart API calls |
+
+`source start.sh unset` clears the `NEXT_PUBLIC_*` variables from your shell.
+
+The deployed app uses none of these. It reads the Cognito settings from the portal's `env.js`
+and derives the mart API host from its own hostname, so nothing environment-specific is baked
+into the build.
+
+## Using another API
+
+`MART_API_URL` is only read by the dev proxy, so the UI can point at any PostGraphile server with
+the same schema, for example to try an API change before it is deployed:
+
+```sh
+make start MART_API_URL=http://localhost:5055
+```
+
+The proxy still sends `MART_API_TOKEN`; a local server without an authorizer ignores it. The API
+server in the orcahouse repo (`infra/api/lambda-server`) runs locally with `pnpm start`, reading
+`DATABASE_URL` and `SCHEMA_NAME=mart`. It listens on port 5000, which macOS often reserves for
+AirPlay Receiver.
+
+## Before you push
+
+```sh
+make check # every pre-commit hook: ESLint, Prettier, secret and file checks
+pnpm build # the static export, as the pipeline builds it
+```
+
+Pull requests run the same hooks plus a type check and a production build.
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+| ---------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
+| `make start` stops with "No valid AWS login session found" | No AWS session for the dev account | `aws sso login --profile dev && export AWS_PROFILE=dev` |
+| The sign-in page says sign-in is not configured | Started with `make dev`, or `start.sh` could not read SSM | Use `make start` and check its output for SSM errors |
+| The hosted UI shows `redirect_mismatch` | The dev server is not on port 3000 | Stop whatever holds port 3000 and restart on it |
+| Tables show "The mart API rejected MART_API_TOKEN (401)" | The token has expired or was copied incompletely | Copy a fresh ID token from portal.umccr.org and restart |
+| Tables show "The mart API rejected the sign-in token" | `MART_API_TOKEN` is not set | Pass `MART_API_TOKEN` to `make start` |
+| No search box above a table | The API does not allow `includesInsensitive`, or the table has no text columns | Check the API's allowed operators (see [Deployment](deployment.md#the-mart-api)), then reload |
+| A table opens to "Not exposed by the API" | The catalogue lists a table the live schema does not have | Nothing to fix in the UI: the table is not deployed in the `mart` schema yet |
diff --git a/src/components/RowSearch.tsx b/src/components/RowSearch.tsx
new file mode 100644
index 0000000..c61012a
--- /dev/null
+++ b/src/components/RowSearch.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { useEffect, useEffectEvent, useRef, useState } from 'react';
+import { Search, X } from 'lucide-react';
+import { humanize, type FilterFieldMeta } from '@/lib/schema';
+import { INPUT } from './ui';
+
+/** How long typing must pause before the search runs, so not every keystroke is a query. */
+const DEBOUNCE_MS = 400;
+
+interface Props {
+ /** Columns the API can search. */
+ fields: FilterFieldMeta[];
+ column: string;
+ /** The search text in the URL. */
+ text: string;
+ onColumnChange: (column: string) => void;
+ onSearch: (text: string) => void;
+}
+
+/**
+ * Search box for rows whose value in the chosen column contains the text, ignoring case.
+ * Typing searches once it pauses and Enter searches at once. The search lives in the URL,
+ * so it can be shared and follows back and forward.
+ */
+export function RowSearch({ fields, column, text, onColumnChange, onSearch }: Props) {
+ const inputRef = useRef(null);
+ const [draft, setDraft] = useState(text);
+ // Searches sent to the URL that have not shown up in it yet, oldest first.
+ const [pending, setPending] = useState([]);
+ const [seenText, setSeenText] = useState(text);
+
+ // The URL text changed. One of our own searches arriving leaves the draft alone, as the
+ // user may have typed on since; any other change, such as back or forward, replaces it.
+ if (text !== seenText) {
+ setSeenText(text);
+ const own = pending.indexOf(text);
+ if (own >= 0) {
+ setPending(pending.slice(own + 1));
+ } else {
+ setPending([]);
+ setDraft(text);
+ }
+ }
+
+ // The text the URL will have once the pending searches arrive.
+ const target = pending.at(-1) ?? text;
+
+ const submit = (value: string) => {
+ const next = value.trim();
+ if (next === target) return;
+ setPending([...pending, next]);
+ onSearch(next);
+ };
+
+ const submitDraft = useEffectEvent(() => submit(draft));
+ useEffect(() => {
+ if (draft.trim() === target) return;
+ const timer = setTimeout(() => submitDraft(), DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [draft, target]);
+
+ const clear = () => {
+ setDraft('');
+ submit('');
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/TableBrowser.tsx b/src/components/TableBrowser.tsx
index 1056bc3..ca0ad14 100644
--- a/src/components/TableBrowser.tsx
+++ b/src/components/TableBrowser.tsx
@@ -1,19 +1,21 @@
'use client';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQuery } from '@apollo/client/react';
+import { ChevronDown } from 'lucide-react';
import { useMartSchema } from '@/hooks/useMartSchema';
import { groupById, resolveCatalog } from '@/lib/catalog';
import { downloadCsv } from '@/lib/csv';
-import { parseFilterJson } from '@/lib/filters';
+import { combineFilters, parseFilterJson } from '@/lib/filters';
import {
buildRowsQuery,
EMPTY_QUERY,
type RowsResult,
type RowsVariables,
} from '@/lib/query-builder';
+import { pickSearchColumn, searchableFields, searchClause } from '@/lib/row-search';
import {
filterFields,
humanize,
@@ -28,6 +30,7 @@ import { DataTable } from './DataTable';
import { ErrorNotice } from './ErrorNotice';
import { FilterBuilder } from './FilterBuilder';
import { DEFAULT_PAGE_SIZE, PAGE_SIZES, Pagination } from './Pagination';
+import { RowSearch } from './RowSearch';
import { StatusBadge } from './StatusBadge';
import { BUTTON } from './ui';
@@ -82,6 +85,7 @@ export function TableBrowser({ table }: Props) {
() => (schema && collection ? orderByValues(schema, collection.orderByType) : []),
[schema, collection]
);
+ const searchFields = useMemo(() => searchableFields(filterMeta), [filterMeta]);
const page = Math.max(1, Number(params.get('page')) || 1);
const sizeParam = Number(params.get('size'));
@@ -89,6 +93,10 @@ export function TableBrowser({ table }: Props) {
const sort = params.get('sort');
const filterRaw = params.get('filter');
const filter = useMemo(() => parseFilterJson(filterRaw), [filterRaw]);
+ // The row search is kept apart from `filter`, which the filter builder owns, and ANDed on.
+ const searchText = params.get('q') ?? '';
+ const searchColumn = pickSearchColumn(searchFields, params.get('qcol'));
+ const defaultSearchColumn = pickSearchColumn(searchFields);
const update = useCallback(
(patch: Patch, replace = false) => {
@@ -116,7 +124,9 @@ export function TableBrowser({ table }: Props) {
}, [collection, columns, defaultSort, filterRaw, sort, update]);
const [hidden, setHidden] = useState>(() => new Set());
- const [filtersOpen, setFiltersOpen] = useState(true);
+ // The advanced filter starts closed: the row search covers most lookups.
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const filterPanelId = useId();
const visible = useMemo(() => columns.filter((c) => !hidden.has(c.name)), [columns, hidden]);
const document = useMemo(
@@ -133,7 +143,7 @@ export function TableBrowser({ table }: Props) {
first: size,
offset: (page - 1) * size,
orderBy: sort ? [sort] : undefined,
- filter: filter ?? undefined,
+ filter: combineFilters(filter, searchClause(searchColumn, searchText)) ?? undefined,
};
const { data, previousData, loading, error, refetch } = useQuery(
document ?? EMPTY_QUERY,
@@ -151,6 +161,14 @@ export function TableBrowser({ table }: Props) {
const prefix = sortPrefix(column);
update({ sort: sort === `${prefix}_DESC` ? `${prefix}_ASC` : `${prefix}_DESC`, page: null });
};
+ // Search updates replace the history entry, so typing does not leave one per pause.
+ const onSearch = (text: string) => update({ q: text || null, page: null }, true);
+ const onSearchColumn = (column: string) => {
+ const patch: Patch = { qcol: column === defaultSearchColumn ? null : column };
+ // Another column only changes the rows, and so the page count, when there is text.
+ if (searchText.trim()) patch.page = null;
+ update(patch, true);
+ };
const exportCsv = () =>
downloadCsv(
`${table}-page${page}-${new Date().toISOString().slice(0, 10)}.csv`,
@@ -202,9 +220,6 @@ export function TableBrowser({ table }: Props) {