Fix/deploy migration safety #26

Merged
bermudalamb merged 5 commits from fix/deploy-migration-safety into main 2026-08-17 12:31:45 -05:00
26 changed files with 1662 additions and 85 deletions
+63 -4
View File
@@ -11,7 +11,7 @@ This is a **self-hosted** application running on a **Synology NAS**, deployed an
## What this is
A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's gone). Public storefront + cart + checkout, with an authentik-SSO-gated admin panel for inventory/customer/settings management.
A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's gone). Public storefront + cart + checkout, with an authentik-SSO-gated admin panel for inventory, category/tag taxonomy, customer, and settings management. The storefront filters items by category, tags, and price range.
## Tech stack
@@ -27,11 +27,18 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
- **DB migrations**: `node-pg-migrate` — NOT Entity Framework (this is a Node/TS backend, EF doesn't apply here). Migration files live in `backend/migrations/`, run via `node migrate.js up` (bundled into the production image specifically so it can be invoked via `docker exec` against the live container). There's no `init.sql` anymore — it was deleted when migrations were introduced; the migration files are the single source of schema truth.
- **Testing**: Jest (unit + integration against a disposable tmpfs Postgres via `node-pg-migrate`), Playwright (e2e)
- **CI**: Gitea Actions — `sonarqube.yml` (static analysis + TS build check) and `tests.yml` (unit/integration/e2e with job summaries posted to Gitea's step summary)
- **Node**: 20+ required. **Node 18 cannot run this project's tooling**`node-pg-migrate` pulls in a `lru-cache` that calls `diagnostics_channel.tracingChannel()`, which doesn't exist before Node 19.9, so `npm run migrate:up` dies with `TypeError: (0 , U.tracingChannel) is not a function`. This is a confusing failure because it looks like a broken dependency rather than a version problem. The dev machine has nvm4w with both 18.16.1 and 24.13.1 installed and **18 is the active default**, so a command can be run against the newer binary without switching globally:
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
```
## Data model (key tables)
- `items` — one-of-a-kind inventory. `status`: `available | reserved | sold`
- `items` — one-of-a-kind inventory. `status`: `available | reserved | sold`. `category_id` is nullable (`NULL` = Uncategorized) and `ON DELETE SET NULL`
- `item_images` — multiple images per item (front/back/etc.), ordered
- `categories` — self-referencing tree (`parent_id`, `ON DELETE CASCADE`), arbitrary depth, one category per item. Two *partial* unique indexes enforce "siblings can't share a name" — a single plain unique constraint doesn't work, because a `NULL` `parent_id` compares unequal to every other `NULL` and duplicate root categories slip straight through
- `tags` — flexible labels with a `color`; unique on `lower(name)`
- `item_tags` — many-to-many between items and tags
- `customers` — accounts, with `marketing_consent` (explicit opt-in, GDPR-style — unchecked by default, timestamped, unsubscribe token)
- `admin_settings` — key/value store, currently just `cart_expiry_hours` (admin-configurable, default 24)
- `carts` / `cart_items` — one cart per customer, items reserved with the admin-configurable expiry; unique constraint on `item_id` means an item can only be in one cart at a time
@@ -48,6 +55,20 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
5. **Admin panel security boundary**: NPM's Advanced nginx config only wraps `auth_request` around `location ~ ^/(admin|api/admin)` — everything else (storefront, cart, checkout, PayPal webhook) bypasses authentik entirely. Deliberate split — don't accidentally widen or narrow that regex without checking both directions.
6. **Cookie `secure` flag** is gated on `NODE_ENV === 'production'`, not hardcoded `true` — otherwise integration tests (plain HTTP, no TLS) silently fail to persist sessions.
7. **`app.ts`/`server.ts` split**: `app.ts` exports the Express app with no `.listen()` call, so tests can import and exercise it via `supertest` without binding a port. `server.ts` is the actual entry point — imports `app`, adds the cart-expiry sweep and cron job, calls `.listen()`. Any new background job or process-level concern goes in `server.ts`, not `app.ts`.
8. **Categories are manual metadata, deliberately not a rule engine.** Issue #23's wording ("rules that dictate how the app automatically organizes items") was explicitly resolved with the author to mean an admin-built tree with a per-item assignment — there is no predicate evaluation anywhere, and adding one would be a new feature, not a completion of this one. Categories are single-valued per item on purpose, to stay distinct from multi-valued tags. **Tag filtering is AND** ("must have all"), not OR.
9. **Item filtering happens in SQL, and malformed filter params return `400`** rather than being ignored — a broken filter link should show itself instead of quietly returning the whole catalogue. Parsing/SQL-building live in `backend/src/itemFilters.ts`, apart from the route so they're unit-testable with no database. Category filtering matches a node *and all descendants* via a recursive CTE; a materialized path column was rejected because reparenting would then have to rewrite every descendant's path, which is a standing drift risk.
10. **Migrations run automatically at container start**, via `CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]` in the `Dockerfile`. Deployed code can therefore never be ahead of the schema. This replaced a separate manual `docker exec ... node migrate.js up` step that was easy to forget — and forgetting it took the storefront down (see the incident note below). `migrate.js` waits for Postgres to accept connections before running (the NAS routinely brings the DB container up slower than the app), and exits non-zero on failure, so `&&` stops a bad migration from serving against a half-migrated schema.
11. **Every async route is wrapped in `asyncRoute()` and the app mounts error middleware.** Express 4 does *not* forward a rejected promise from an async handler, and with no error middleware such a request **never responds at all** — it hangs until the client gives up. A hung request is indistinguishable from an empty result in the UI. New async routes must use the wrapper (`backend/src/asyncRoute.ts`); it becomes unnecessary only if the project moves to Express 5. Note the older route files predate this and are still unwrapped.
12. **Item SELECTs aggregate with scalar subqueries, never `LEFT JOIN` + `GROUP BY`** — see `backend/src/itemSelect.ts`, the single source for both the public and admin shapes. Joining two one-to-many relations in one query multiplies their rows together: with the old shape, an item with 2 images and 3 tags repeated every image three times. This bit once already when tags were added. If a third one-to-many relation is ever attached to items, extend `itemSelect.ts` the same way rather than adding a join.
## Frontend gotchas
- **antd's `defaultExpandAll` on `Tree` is evaluated once, at mount.** Any branch created *after* that renders collapsed, and its children become unreachable — in the admin Categories tab this meant adding a subcategory made it appear to vanish. Anywhere a tree outlives the data it displays, track `expandedKeys` in state instead and expand the parent when a node is added or moved. The storefront's filter drawer gets away with `defaultExpandAll` only because `destroyOnHidden` remounts it after the categories have loaded — that's a coincidence, not a pattern to copy.
- **`destroyOnClose` is deprecated in antd 5.29** in favour of `destroyOnHidden`. Several older files (`Admin.tsx`, `Cart.tsx`, `AuthPromptModal.tsx`) still use the old prop and log a console warning; new code uses `destroyOnHidden`.
- **A closed antd `Drawer` keeps its content in the DOM** unless `destroyOnHidden` is set, so duplicate control labels (two "Clear all" buttons, say) collide in Playwright's strict mode even when only one is on screen. The active-filter chip row is marked `role="group" aria-label="Active filters"` so its controls stay addressable independently of the drawer's.
- **An antd `Button` with an icon does not have the plain text as its accessible name** — the icon contributes leading whitespace, so `getByRole('button', { name: /^Filters/ })` matches nothing while `/Filters/` and the exact string `'Filters'` both work. Prefer a substring regex for icon buttons.
- **A failed request must never render as an empty result.** `App.tsx` tracks a `failed` flag separately from `loading`, and shows an error with a Retry rather than falling through to `Empty`. Saying "no items yet" when the server is broken hides an outage and reads to a customer as an empty shop — this is exactly how the 2026-08-17 incident presented. `fetchItems`/`fetchFilterOptions` throw on a non-OK response rather than returning the parsed error body, which would otherwise be set as the item list and crash the grid on `.map`. Covered by `tests/e2e/storefront-errors.spec.ts`.
- **antd import style is inconsistent across the codebase.** New files use deep imports (`import Drawer from 'antd/lib/drawer'`) per the user's standing rule; most older files still use barrel imports from `'antd'`. Don't churn existing files just to convert them.
## Deployment workflow — read this before touching the NAS
@@ -117,6 +138,10 @@ name = Thom Lamb
- **Always double check `git status` shows a file as staged before committing**, and check it again after committing to confirm a clean working tree before pushing. This project has repeatedly had files silently left out of a commit (most notably `Admin.tsx` once, and the entire cart feature's branch/push never happening at all despite believing it had) — the cost of that mistake compounds badly (hours of debugging "why doesn't the deployed code match what I was shown"), so the few seconds to verify `git status` at each step is well worth it.
- **Incident (2026-08-17): the storefront showed no inventory after a deploy.** No data was lost — the cause was deploying code that queried `categories` / `item_tags` / `items.category_id` against a database where that migration hadn't been run yet. Because Express 4 doesn't forward async errors and no error middleware existed, every item query **hung with no response**, and the frontend rendered that as an empty catalogue. Three changes now prevent this class: migrations run at container start (so code can't outrun the schema), an error middleware guarantees a response, and the storefront distinguishes "request failed" from "no items". **The general lesson is broader than migrations: any deploy where new code needs something the running environment doesn't have yet will surface as silence, not an error, unless something is written to make it loud.**
- **Check `git branch --contains <sha>` before assuming a commit shipped.** A documentation commit made after the feature branch was pushed was left out of PR #24 entirely and ended up dangling — reachable only through the reflog. `git log` on the branch looked normal, because the missing commit simply wasn't there. If a commit was made after the push, confirm it actually made it onto the branch that got merged.
### Standard deploy sequence, once code is confirmed on `main`
```bash
@@ -128,20 +153,36 @@ sudo docker build --no-cache -t redefined-designs:latest /volume1/docker/redefin
sudo docker stop redefined-designs-syn
sudo docker rm redefined-designs-syn
# redeploy the stack in Portainer UI
# migrations now run automatically as the container starts — no separate step
sudo docker logs redefined-designs-syn | head -20 # confirm migrations ran before the app came up
```
### Running migrations against the live DB (via Portainer-managed container)
### Migrations against the live DB
**Migrations apply automatically at container start**, so a normal deploy needs no manual step. `docker logs` on the app container shows the migration output ahead of the server starting; a failed migration exits the container rather than serving a half-migrated schema.
The manual invocation still works and is useful for applying a migration without redeploying, or for inspecting state:
```bash
sudo docker exec redefined-designs-syn node migrate.js up
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on;"
```
If the storefront ever looks empty after a deploy, check the row count before assuming data loss — the far more likely cause is a schema/code mismatch:
```bash
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT count(*) FROM items;"
```
## Conventions
- **Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/)**: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `ci:`, `refactor:`, etc.
- **Never commit directly to `main`.** Always branch: `feature/<short-description>` or `fix/<short-description>`. Open a PR, merge, branch auto-deletes (repo setting is on).
- Local dev/editing happens in **VS Code**, pushed via **PowerShell** `git` — the NAS-side `gitc` workflow is *only* for pulling already-merged code down to deploy, never for authoring changes.
- **Thom does the pushing.** Commit locally and stop; don't `git push` on his behalf.
- **When work comes from a Gitea issue, post every clarifying question and its answer back to that issue as a comment** — including the options considered and why the rejected ones were rejected. The issue is the durable record; decisions made in a chat session are invisible to anyone reading it later. Post each round as the answers come in rather than batching everything to the end.
- **Design specs live in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`** and are committed before implementation starts.
- **`.superpowers/` stays gitignored, but design artifacts inside it must be lifted out before they are lost.** That directory is scratch state belonging to the brainstorming tool and contains a session token, PID files, and absolute local paths — none of which belong in the repo. The mockups it holds *are* worth keeping, so copy them into `docs/superpowers/specs/<date>-<topic>-mockups/` and wrap them as standalone pages (they are served as fragments inside a tool-provided frame, so they need its style tokens and `toggleSelect` helper inlined to open on their own). Keep the rejected options, not just the chosen one — the value is in the comparison.
## Testing
@@ -150,6 +191,15 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
- **E2e (Playwright)**: needs backend running against a migrated DB; `cd frontend && npm run test:e2e`
- CI (`tests.yml`) runs all three as separate jobs, each posting a pass/fail summary to Gitea's job Summary tab. The `frontend-e2e` job runs `node migrate.js up` against a Postgres service container — same migration mechanism as everywhere else, no schema duplication anywhere in the project anymore.
### E2E constraints — the local database is never reset
Integration tests truncate between cases (`resetDb()` in `tests/integration/setup/testDb.ts`**add any new table to that TRUNCATE list**, or state leaks between tests). Playwright specs have no such hook and run against whatever is already there, which locally accumulates across every previous run. Consequences worth knowing before writing a new spec:
- **Name every fixture with a per-run suffix** and assert on those names only. A bare `.first()` or an unscoped `getByText` will find leftovers from an earlier run and produce a failure that looks like a product bug but isn't. This cost real debugging time.
- **`beforeAll` runs once per worker, but a worker can be handed the same spec file in more than one batch**, re-running it against the module-cached suffix. `filters.spec.ts` therefore checks whether its fixtures already exist and returns early — without that, the second pass 409s on category names and duplicates every item, which then breaks strict-mode locators.
- **Tables paginate.** With dozens of accumulated rows a freshly created record often isn't on page 1; `admin-taxonomy.spec.ts` confirms tag creation through the API rather than hunting for the row.
- **Clicking a submit button only dispatches the request.** Wait for the resulting confirmation (`'Tag added'`) before querying the API, or the read races the write. This produced a one-in-four flake until fixed.
## Known gaps / natural next steps
- **No CD** — Gitea Actions runs tests/analysis but doesn't deploy. Manual NAS rebuild is still required after every merge.
@@ -159,10 +209,19 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
- **Daily cart reminder emails** fire via `node-cron` inside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode.
- **Old single-item checkout route files** (`backend/src/routes/paypal.ts`, `backend/src/routes/demo.ts`) are dead code, unmounted but never deleted — safe cleanup opportunity.
- **`backend/src/routes/shippingAddresses.ts` USPS OAuth token format** was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-body `client_credentials` request — if USPS changes their API again, this is the first place to check.
- **`TAG_COLORS` is duplicated** between `backend/src/utils.ts` and `frontend/src/admin/Tags.tsx`. The server validates against its copy, so editing one alone makes the admin colour picker offer values that get rejected with a `400`. There's no shared module between backend and frontend in this repo to put it in.
- **The local e2e database accumulates junk indefinitely** — every Playwright run seeds categories, tags, and items that are never cleaned up, so the admin tables and filter drawer fill with `Furniture fmsxb…` noise over time. Harmless, but `npm run db:test:down` + `db:test:up` + `migrate:up` resets it when the clutter starts getting in the way. CI is unaffected (fresh service container per run).
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Unchanged by the filters work — deliberately left as-is, but worth revisiting if sold stock ever outnumbers available stock.
- **No admin-side filtering.** The admin inventory table shows category and tag columns but can't filter or search on them; with 100+ items that will start to hurt.
## Where to look first for common tasks
- Add/change a DB table → new file in `backend/migrations/` via `npm run migrate:create -- descriptive-name`, fill in `pgm.sql(...)` for up/down
- Add/change a DB table → new file in `backend/migrations/` via `npm run migrate:create -- descriptive-name`, fill in `pgm.sql(...)` for up/down, **and add the table to `resetDb()`** in `backend/tests/integration/setup/testDb.ts`
- Change cart/checkout behavior → `backend/src/routes/cartCheckout.ts` (the whole reserve → checkout → complete lifecycle lives here)
- Change admin-configurable settings → `admin_settings` table + `backend/src/routes/adminSettings.ts` + `frontend/src/admin/Settings.tsx`
- Add a new async route → wrap the handler in `asyncRoute()` from `backend/src/asyncRoute.ts`, or a failure will hang the request instead of returning 500
- Change what an item row returns → `backend/src/itemSelect.ts` (one place, used by both the public and admin routes)
- Change storefront filtering → `backend/src/itemFilters.ts` (parsing + SQL), `backend/src/routes/filters.ts` (`/api/filters`, the drawer's single fetch), `frontend/src/filters.ts` (state, URL round-trip, tree building), `frontend/src/components/FilterDrawer.tsx`
- Change category/tag management → `backend/src/routes/adminCategories.ts`, `backend/src/routes/adminTags.ts`, `frontend/src/admin/Categories.tsx`, `frontend/src/admin/Tags.tsx`
- Change tag colours → `TAG_COLORS` + `tagColorFor()` in `backend/src/utils.ts`; the list is **duplicated** in `frontend/src/admin/Tags.tsx` for the override picker, and the server rejects anything outside it, so the two must be changed together
- NPM/authentik/DSM reverse-proxy config for this app → not in this repo; documented in the broader homelab's Claude Project knowledge base, not here
+9 -1
View File
@@ -22,4 +22,12 @@ COPY --from=backend-build /app/backend/migrations ./migrations
COPY --from=frontend-build /app/frontend/dist ./public
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Migrations run before the app serves, so deployed code can never be ahead of
# the database schema. Previously this was a separate, manual
# `docker exec ... node migrate.js up` that was easy to forget — and forgetting
# it meant every item query referenced tables that did not exist yet, which
# surfaced as an empty storefront rather than an error.
#
# `&&` matters: migrate.js exits non-zero on failure, so a bad migration stops
# the container instead of letting it serve against a half-migrated schema.
CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]
+74
View File
@@ -161,3 +161,77 @@ Gitea Actions runs two workflows on every push to `main` and on pull requests:
## Production deployment
Production runs as a single Docker image (multi-stage build — the frontend is built to static files and served directly by the backend), deployed via Portainer behind Nginx Proxy Manager, with authentik forward-auth gating `/admin`. That infrastructure is homelab-specific and documented separately outside this repo.
The container applies pending migrations before starting the server, so deployed code can never be ahead of the database schema. A failed migration stops the container rather than letting it serve against a schema it doesn't match — check `docker logs` on the app container if it doesn't come up.
## QA environment
`docker-compose.qa.yml` defines a disposable QA stack for reviewing merged-but-undeployed changes online. It runs alongside production on the same NAS with its own containers, database, volumes, and host port, and is started only when a review is needed.
It differs from production deliberately: `DEMO_MODE=true` with no PayPal credentials (so checkout is exercisable but cannot reach live PayPal), no SMTP configuration (so it cannot email anyone), and `restart: "no"` (so a NAS reboot doesn't quietly bring it back up).
### One-time setup on the NAS
**Build the image before creating the stack.** `redefined-designs:qa` exists only on the NAS — it is never pushed to a registry. If the stack is deployed first, Compose finds no local image, falls back to pulling from Docker Hub, and fails with a misleading `pull access denied ... repository does not exist or may require 'docker login'`.
```bash
cd /volume1/docker/redefined-designs
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
sudo docker images | grep redefined-designs # confirm the qa tag is present
```
For the same reason, leave Portainer's **"Pull latest image"** toggle **off** when deploying or redeploying this stack. The compose file sets `pull_policy: never` so a missing image reports itself as missing rather than as a registry authentication problem.
Next, create the directory structure. These paths are deliberately **not** under `/volume1/configs/redefined-designs` — sharing production's Postgres directory would mean QA writing into production's database files.
```bash
sudo mkdir -p /volume1/configs/redefined-designs-qa/postgres
sudo mkdir -p /volume1/configs/redefined-designs-qa/uploads
# The official postgres image runs as uid/gid 999. Without this the DB
# container exits immediately with a data-directory permissions error.
sudo chown -R 999:999 /volume1/configs/redefined-designs-qa/postgres
sudo chmod 700 /volume1/configs/redefined-designs-qa/postgres
# Uploads are written by the app container, which runs as root.
sudo chown -R root:root /volume1/configs/redefined-designs-qa/uploads
sudo chmod 755 /volume1/configs/redefined-designs-qa/uploads
# Confirm the two environments are separate before going further.
ls -la /volume1/configs/redefined-designs-qa/
ls -la /volume1/configs/redefined-designs/
```
Then, in Portainer, create a stack **named `redefined-designs-qa`** from `docker-compose.qa.yml`, with one environment variable `QA_DB_PASSWORD`. The stack name matters: it becomes the compose project name, and reusing production's name would make compose reconcile the two stacks against each other and remove the production containers.
Finally, add an Nginx Proxy Manager host for `qa-redefined-designs` pointing at the NAS on port `32751`, with authentik forward-auth on `location /` — the whole site, not just `/admin`, so nothing unreleased is publicly reachable.
### Reviewing a change
```bash
cd /volume1/docker/redefined-designs
gitc() { sudo docker run --rm -it -v /volume1/docker/redefined-designs:/repo -v ~/.gitconfig-docker/.gitconfig:/root/.gitconfig -w /repo alpine/git "$@"; }
gitc pull origin main
gitc log --oneline -3
# Rebuild the QA image before restarting the stack — the stack does not build
# anything itself, and will otherwise run whatever was last tagged :qa.
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
# start the redefined-designs-qa stack in Portainer
# Migrations run as the container starts — confirm before reviewing.
sudo docker logs redefined-designs-qa-syn | head -30
```
Stop the stack in Portainer when finished.
### Resetting QA data
QA data is disposable. To start from an empty database, stop the stack first, then:
```bash
# Check the path before running this. It must contain -qa.
sudo rm -rf /volume1/configs/redefined-designs-qa/postgres/pgdata
```
Restarting the stack recreates the database and re-runs every migration from scratch.
+35 -3
View File
@@ -1,22 +1,54 @@
const { runner } = require('node-pg-migrate');
const { Client } = require('pg');
const path = require('path');
const direction = process.argv[2] || 'up';
runner({
databaseUrl: {
const dbConfig = {
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE
},
};
// This now runs at container start, ahead of the app, so it can come up before
// Postgres is accepting connections — on the NAS the database container is
// routinely slower to be ready than the app container. Without a wait the
// migration would fail, take the app down with it, and look like a broken
// deploy rather than a startup race.
const WAIT_ATTEMPTS = 30;
const WAIT_INTERVAL_MS = 2000;
async function waitForDb() {
for (let attempt = 1; attempt <= WAIT_ATTEMPTS; attempt++) {
const client = new Client(dbConfig);
try {
await client.connect();
await client.end();
return;
} catch (err) {
await client.end().catch(() => {});
if (attempt === WAIT_ATTEMPTS) {
throw new Error(`Database unreachable after ${WAIT_ATTEMPTS} attempts: ${err.message}`);
}
console.log(`Waiting for database (attempt ${attempt}/${WAIT_ATTEMPTS})...`);
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
}
}
}
waitForDb()
.then(() =>
runner({
databaseUrl: dbConfig,
dir: path.resolve(__dirname, 'migrations'),
direction,
migrationsTable: 'pgmigrations',
count: direction === 'down' ? 1 : Infinity,
log: (msg) => console.log(msg)
})
)
.then((applied) => {
console.log(`Migration complete — ${applied.length} migration(s) ${direction === 'down' ? 'reverted' : 'applied'}.`);
process.exit(0);
+17 -1
View File
@@ -1,4 +1,4 @@
import express from 'express';
import express, { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import path from 'path';
import itemsRouter from './routes/items';
@@ -58,4 +58,20 @@ if (process.env.NODE_ENV !== 'test') {
});
}
// Mounted last, so it sees errors from every route above. Without it, a route
// that hands an error to next() falls through to Express's default handler,
// and — worse — an async route that rejects never responds at all, leaving the
// client hanging. A hung request is indistinguishable from an empty catalogue
// in the UI, which is exactly how a schema mismatch once read as "the store
// has no items". Always answer.
//
// The four-argument signature is what marks this as error middleware; `next`
// is unused but must stay for Express to recognise it.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal error' });
});
export default app;
+20
View File
@@ -0,0 +1,20 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
// Express 4 does not forward a rejected promise from an async handler to the
// error middleware. An async route that throws therefore never responds at all
// — the request hangs until the client gives up, which reads to a user as "the
// page is empty" rather than "the server failed". Wrapping the handler routes
// the rejection into next(), so the error middleware can answer with a 500.
//
// Express 5 does this natively; drop this helper if the project ever upgrades.
export function asyncRoute(
handler: (req: Request, res: Response, next: NextFunction) => unknown
): RequestHandler {
return (req, res, next) => {
// Promise.resolve also captures a synchronous throw, so both failure modes
// reach the same place.
return Promise.resolve()
.then(() => handler(req, res, next))
.catch(next);
};
}
+9 -8
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
@@ -36,7 +37,7 @@ async function parentExists(id: number): Promise<boolean> {
return rows.length > 0;
}
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT c.id, c.name, c.parent_id, c.sort_order,
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
@@ -44,9 +45,9 @@ router.get('/', async (_req: Request, res: Response) => {
ORDER BY c.sort_order, lower(c.name)`
);
res.json(rows);
});
}));
router.post('/', async (req: Request, res: Response) => {
router.post('/', asyncRoute(async (req: Request, res: Response) => {
const name = readName(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
@@ -76,9 +77,9 @@ router.post('/', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.put('/:id', async (req: Request, res: Response) => {
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
if (!existing.rows.length) {
@@ -134,9 +135,9 @@ router.put('/:id', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.delete('/:id', async (req: Request, res: Response) => {
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
if (!subtree.length) {
@@ -154,6 +155,6 @@ router.delete('/:id', async (req: Request, res: Response) => {
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
});
}));
export default router;
+9 -8
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { TAG_COLORS, tagColorFor } from '../utils';
const router = Router();
@@ -20,7 +21,7 @@ function readColor(value: unknown): string | null | undefined {
return value;
}
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT t.id, t.name, t.color,
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
@@ -28,9 +29,9 @@ router.get('/', async (_req: Request, res: Response) => {
ORDER BY lower(t.name)`
);
res.json(rows);
});
}));
router.post('/', async (req: Request, res: Response) => {
router.post('/', asyncRoute(async (req: Request, res: Response) => {
const name = readName(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
@@ -54,9 +55,9 @@ router.post('/', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.put('/:id', async (req: Request, res: Response) => {
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]);
if (!existing.rows.length) {
@@ -93,12 +94,12 @@ router.put('/:id', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.delete('/:id', async (req: Request, res: Response) => {
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
// item_tags cascades; the items themselves are untouched.
await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
res.status(204).end();
});
}));
export default router;
+3 -2
View File
@@ -1,12 +1,13 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
// Everything the storefront's filter drawer needs, in one request: the whole
// category tree (flat — the frontend nests it), every tag with its colour, and
// the catalogue's price bounds for the slider.
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const [categories, tags, price] = await Promise.all([
pool.query(
`SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)`
@@ -31,6 +32,6 @@ router.get('/', async (_req: Request, res: Response) => {
tags: tags.rows,
priceRange: price.rows[0]
});
});
}));
export default router;
+5 -4
View File
@@ -1,11 +1,12 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
router.get('/', asyncRoute(async (req: Request, res: Response) => {
let filters;
try {
filters = parseItemFilters(req.query as Record<string, unknown>);
@@ -22,12 +23,12 @@ router.get('/', async (req: Request, res: Response) => {
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows);
});
}));
router.get('/:id', async (req: Request, res: Response) => {
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
}));
export default router;
@@ -0,0 +1,34 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('unexpected route failures', () => {
it('answers with 500 instead of leaving the request hanging', async () => {
// A non-numeric id reaches Postgres as `WHERE i.id = 'not-a-number'`, which
// raises invalid-input-syntax. Express 4 does not forward a rejected async
// handler on its own, so without the asyncRoute wrapper plus the error
// middleware this request never gets a response at all — and a hung request
// renders as an empty storefront rather than a visible failure.
const res = await request(app).get('/api/items/not-a-number');
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal error');
});
it('does not leak the underlying database error to the client', async () => {
const res = await request(app).get('/api/items/not-a-number');
expect(JSON.stringify(res.body)).not.toContain('syntax');
expect(JSON.stringify(res.body)).not.toContain('items');
});
});
+44
View File
@@ -0,0 +1,44 @@
import { Request, Response, NextFunction } from 'express';
import { asyncRoute } from '../../src/asyncRoute';
function fakeArgs() {
const next = jest.fn() as unknown as NextFunction;
return { req: {} as Request, res: {} as Response, next };
}
describe('asyncRoute', () => {
it('passes a rejected handler to next so Express can answer the request', async () => {
const boom = new Error('relation "categories" does not exist');
const { req, res, next } = fakeArgs();
await asyncRoute(async () => { throw boom; })(req, res, next);
expect(next).toHaveBeenCalledWith(boom);
});
it('passes a synchronous throw to next as well', async () => {
const boom = new Error('sync failure');
const { req, res, next } = fakeArgs();
await asyncRoute(() => { throw boom; })(req, res, next);
expect(next).toHaveBeenCalledWith(boom);
});
it('leaves a successful handler alone', async () => {
const { req, res, next } = fakeArgs();
await asyncRoute(async () => 'fine')(req, res, next);
expect(next).not.toHaveBeenCalled();
});
it('forwards the same arguments it was given', async () => {
const handler = jest.fn(async () => undefined);
const { req, res, next } = fakeArgs();
await asyncRoute(handler)(req, res, next);
expect(handler).toHaveBeenCalledWith(req, res, next);
});
});
+94
View File
@@ -0,0 +1,94 @@
# QA stack — a disposable copy of the app for reviewing merged-but-undeployed
# changes online. See issue #25.
#
# Deployed as its own Portainer stack, separate from production. Every value
# that could collide with production has been changed: container names, host
# port, volume paths, database name, and image tag. Do not copy a path or port
# back from the production stack — a shared Postgres data directory would mean
# QA writing into production's database files.
#
# Name the Portainer stack `redefined-designs-qa`, NOT `redefined-designs`.
# The stack name becomes the compose project name. Reusing production's name
# would make compose treat this as the same project and reconcile the two
# against each other — it would happily remove the production containers
# because they are not declared in this file.
#
# Required stack environment variable:
# QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the
# production stack's variables here does nothing silently.
#
# BUILD THE IMAGE BEFORE DEPLOYING THIS STACK. redefined-designs:qa exists only
# on the NAS and is never pushed to a registry, so deploying first makes Compose
# fall back to pulling from Docker Hub and fail with a misleading
# "pull access denied ... repository does not exist or may require docker login".
#
# sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
#
# For the same reason, leave Portainer's "Pull latest image" toggle off.
# `pull_policy: never` below makes a missing image report itself as missing
# rather than as a registry authentication problem. If the Docker Compose
# version on the NAS ever rejects that key, it is safe to delete the line — it
# only improves the error message.
services:
redefined-designs-qa:
image: redefined-designs:qa
pull_policy: never
container_name: redefined-designs-qa-syn
environment:
- TZ=America/Chicago
- PORT=3000
- PGHOST=redefined-designs-qa-db-syn
- PGPORT=5432
- PGUSER=redefined_qa
- PGPASSWORD=${QA_DB_PASSWORD}
- PGDATABASE=redefined_qa
# No PayPal credentials at all. DEMO_MODE lets the full cart and checkout
# flow run without them, so QA can exercise the whole purchase path with
# no way to reach live PayPal. Never set PAYPAL_ENV=live here. To test a
# real PayPal integration change, add sandbox credentials and set
# PAYPAL_ENV=sandbox — never the live ones.
- DEMO_MODE=true
# No SMTP configuration either. The mailer degrades gracefully when
# unconfigured: it logs a warning and skips sending. That is the desired
# behaviour here — a QA run must not be able to email real customers if
# a fixture ever contains a real address.
- SITE_CURRENCY=USD
- RESERVATION_MINUTES=15
- PUBLIC_URL=https://qa-redefined-designs.bermudalamb.synology.me
volumes:
# Separate uploads directory. Sharing production's would let a QA run
# write into, and a QA teardown delete, real product images.
- /volume1/configs/redefined-designs-qa/uploads:/app/uploads
ports:
# 32751, not production's 32750.
- 32751:3000
depends_on:
redefined-designs-qa-db-syn:
condition: service_healthy
# Not `unless-stopped`: QA is meant to be up only while a review is
# happening. `unless-stopped` would silently bring it back after every NAS
# reboot and leave it running indefinitely.
restart: "no"
redefined-designs-qa-db-syn:
image: postgres:16
container_name: redefined-designs-qa-db-syn
environment:
- POSTGRES_USER=redefined_qa
- POSTGRES_PASSWORD=${QA_DB_PASSWORD}
- POSTGRES_DB=redefined_qa
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
# Distinct data directory from production's
# /volume1/configs/redefined-designs/postgres. This is the single most
# important difference in this file.
- /volume1/configs/redefined-designs-qa/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U redefined_qa -d redefined_qa"]
interval: 10s
timeout: 5s
retries: 10
restart: "no"
@@ -40,6 +40,10 @@ The issue's phrase "rules that dictate how the app automatically organizes items
resolved to mean manual tree assignment, matching its own follow-on sentence that categories are
"only metadata for organizing the items into a tree-like structure."
The layout decisions were made against wireframes, archived alongside this spec in
[`2026-08-17-categories-and-tags-mockups/`](2026-08-17-categories-and-tags-mockups/) — they record
the rejected options (sidebar rail, dropdown row, bottom sheet) as well as the chosen one.
## Schema
One new migration, created with `npm run migrate:create -- add-categories-and-tags`.
@@ -0,0 +1,27 @@
# Categories and tags — design mockups
Wireframes produced while designing [issue #23](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23),
kept because they record the options that were rejected, not just the one that shipped.
Open either file directly in a browser — they are self-contained, with no server,
build step, or network access required.
| File | Question it answered | Outcome |
| --- | --- | --- |
| `filter-layout.html` | Where do the storefront's category / tag / price filters live? | **C — drawer plus removable chips.** A was a permanent sidebar rail (most discoverable, but costs ~25% of grid width); B was a row of dropdowns (full width, but hides the category tree until opened). |
| `filter-layout-mobile.html` | How does that drawer arrive on a phone? | **C1 — side drawer**, entering from the right at near full-screen. C2 was a bottom sheet: better thumb reach, but a deep category tree gets cramped. |
The reasoning behind each choice is in
[the design spec](../2026-08-17-categories-and-tags-design.md), and the
question-and-answer trail is on the issue itself.
## Why these are copies
These began as content fragments inside `.superpowers/brainstorm/`, a scratch
directory belonging to the brainstorming tool. That directory is gitignored and
should stay that way — it also holds a session token, PID files, and absolute
local paths, none of which belong in the repository.
The mockups themselves are design artifacts, so they were lifted out and wrapped
into standalone pages: the frame's style tokens and its `toggleSelect` helper are
inlined here, which is the only difference from what was reviewed.
@@ -0,0 +1,378 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mobile drawer variants</title>
<!--
Archived design mockup from the brainstorming session for issue #23.
Originally a content fragment served inside a tool-provided frame; the
frame's tokens and helper are inlined below so the file opens standalone
in any browser with no server and no network access.
-->
<style>
:root {
--border: #d9d9d9;
--bg: #ffffff;
--bg-elevated: #ffffff;
--text: #1a1a1a;
--accent: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--border: #3a3a3a;
--bg: #141414;
--bg-elevated: #1d1d1d;
--text: #e8e8e8;
--accent: #60a5fa;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
max-width: 1400px;
margin-inline: auto;
}
h2 { margin: 0 0 8px; font-size: 24px; }
h3 { margin: 0 0 6px; font-size: 16px; }
h4 { margin: 0; }
.subtitle { margin: 0 0 24px; opacity: .75; font-size: 15px; }
.section { margin-bottom: 24px; }
.label {
font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
opacity: .6; font-weight: 700; margin: 0 0 6px;
}
code {
background: rgba(128,128,128,.14); padding: 1px 5px;
border-radius: 4px; font-size: .9em;
}
.archive-note {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 6px; padding: 12px 16px; margin-bottom: 28px;
font-size: 14px; background: var(--bg-elevated);
}
</style>
</head>
<body>
<div class="archive-note">
<strong>Archived mockup — Mobile drawer variants.</strong>
Produced while designing
<a href="https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23">issue #23</a>.
The option marked as chosen is recorded in
<a href="../2026-08-17-categories-and-tags-design.md">the design spec</a>.
</div>
<style>
.mb-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; align-items: start; justify-items: center; }
@media (max-width: 1000px) { .mb-row { grid-template-columns: 1fr; } }
.mb-col { width: 100%; max-width: 300px; }
.mb-cap { text-align: center; font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.mb-sub { text-align: center; font-size: 12px; opacity: .7; margin-bottom: 12px; line-height: 1.5; min-height: 34px; }
/* phone chrome */
.phone {
border: 8px solid #2a2a2a; border-radius: 26px;
background: var(--bg-elevated, #fff);
overflow: hidden; position: relative;
height: 500px;
box-shadow: 0 6px 18px rgba(0,0,0,.22);
}
.phone-notch {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 84px; height: 15px; background: #2a2a2a;
border-radius: 0 0 9px 9px; z-index: 40;
}
.scr { position: absolute; inset: 0; display: flex; flex-direction: column; font-size: 11px; }
.m-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 10px 8px; border-bottom: 1px solid var(--border, #ccc);
background: rgba(128,128,128,.08); flex: 0 0 auto;
}
.m-brand { font-weight: 700; font-size: 12px; }
.m-icons { display: flex; gap: 6px; }
.m-ico {
width: 22px; height: 22px; border: 1px solid var(--border, #ccc); border-radius: 5px;
display: flex; align-items: center; justify-content: center; font-size: 11px;
background: rgba(128,128,128,.06);
}
.m-filterbtn {
margin: 8px 10px 0; padding: 7px; border-radius: 6px;
background: #444; color: #fff; text-align: center; font-weight: 600; font-size: 11px;
display: flex; align-items: center; justify-content: center; gap: 6px;
flex: 0 0 auto;
}
.m-count {
background: #fff; color: #444; border-radius: 9px;
padding: 0 6px; font-size: 10px; font-weight: 700;
}
.m-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 10px; flex: 0 0 auto; }
.m-chip {
border: 1px solid var(--accent, #3b82f6); color: var(--accent, #3b82f6);
border-radius: 10px; padding: 2px 7px; font-size: 10px; white-space: nowrap;
}
.m-chip.clear { border-color: var(--border, #bbb); opacity: .7; color: inherit; }
.m-scroll { flex: 1; overflow: hidden; padding: 0 10px 10px; }
.m-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.m-card { border: 1px solid var(--border, #ccc); border-radius: 6px; overflow: hidden; }
.m-photo { height: 62px; background: repeating-linear-gradient(45deg, rgba(128,128,128,.14) 0 6px, rgba(128,128,128,.05) 6px 12px); }
.m-meta { padding: 5px 6px; line-height: 1.5; }
.m-meta b { display: block; font-size: 10px; font-weight: 600; }
.m-meta span { font-size: 10px; opacity: .75; }
.m-tagrow { display: flex; gap: 3px; margin-top: 4px; }
.m-tag { border-radius: 8px; padding: 0 5px; font-size: 8px; color: #fff; white-space: nowrap; }
/* drawer variants */
.m-scrim { position: absolute; inset: 0; background: rgba(0,0,0,.45); z-index: 20; }
.m-sheet-right {
position: absolute; top: 0; right: 0; bottom: 0; left: 10%;
background: var(--bg-elevated, #fff); z-index: 30;
display: flex; flex-direction: column;
box-shadow: -8px 0 18px rgba(0,0,0,.28);
}
.m-sheet-bottom {
position: absolute; left: 0; right: 0; bottom: 0; height: 78%;
background: var(--bg-elevated, #fff); z-index: 30;
border-radius: 14px 14px 0 0;
display: flex; flex-direction: column;
box-shadow: 0 -8px 18px rgba(0,0,0,.28);
}
.m-grab { width: 34px; height: 4px; border-radius: 2px; background: rgba(128,128,128,.5); margin: 7px auto 0; }
.m-sheet-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px; border-bottom: 1px solid var(--border, #ccc); font-weight: 700; font-size: 12px;
flex: 0 0 auto;
}
.m-sheet-body { flex: 1; overflow: hidden; padding: 10px; }
.m-sheet-foot {
display: flex; gap: 8px; padding: 9px 10px;
border-top: 1px solid var(--border, #ccc); flex: 0 0 auto;
}
.m-foot-btn {
flex: 1; text-align: center; padding: 7px; border-radius: 6px; font-size: 11px; font-weight: 600;
border: 1px solid var(--border, #ccc);
}
.m-foot-btn.primary { background: #444; color: #fff; border-color: #444; }
.m-lab {
font-size: 9px; letter-spacing: .07em; text-transform: uppercase;
opacity: .6; font-weight: 700; margin: 12px 0 5px;
}
.m-lab:first-child { margin-top: 0; }
.m-tree { line-height: 2; font-size: 11px; }
.m-tree .on { font-weight: 700; background: rgba(59,130,246,.2); border-radius: 4px; padding: 1px 5px; }
.m-tree .muted { opacity: .6; }
.m-pills { display: flex; flex-wrap: wrap; gap: 5px; }
.m-pill { border-radius: 10px; padding: 3px 9px; font-size: 10px; border: 1px solid transparent; }
.m-pill.off { background: rgba(128,128,128,.12); border-color: var(--border, #ccc); opacity: .8; }
.m-slider { padding: 8px 4px 0; }
.m-track { height: 4px; background: rgba(128,128,128,.3); border-radius: 2px; position: relative; }
.m-fill { position: absolute; left: 18%; right: 28%; top: 0; bottom: 0; background: var(--accent, #3b82f6); border-radius: 2px; }
.m-knob { position: absolute; top: -5px; width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 3px solid var(--accent, #3b82f6); }
.m-range { display: flex; justify-content: space-between; font-size: 10px; margin-top: 8px; opacity: .85; }
.pick { border: 2px solid transparent; border-radius: 12px; padding: 6px; cursor: pointer; transition: border-color .15s; }
.pick:hover { border-color: rgba(128,128,128,.4); }
.pick.selected { border-color: var(--accent, #3b82f6); }
.magenta { background: #c41d7f; color: #fff; }
.green { background: #389e0d; color: #fff; }
.blue { background: #096dd9; color: #fff; }
</style>
<h2>Option C on a phone — and one sub-choice</h2>
<p class="subtitle">
The closed state is the same either way. What differs is how the filter panel arrives: a
<b>full-height side drawer</b> or a <b>bottom sheet</b>. Both are one antd <code>Drawer</code> with a
different <code>placement</code>, so this is purely a feel decision. Click the drawer style you prefer.
</p>
<div class="mb-row">
<!-- ===== 1. CLOSED STATE ===== -->
<div class="mb-col">
<div class="mb-cap">Closed — browsing</div>
<div class="mb-sub">Full-width button, applied filters as removable chips, two cards per row.</div>
<div class="phone">
<div class="phone-notch"></div>
<div class="scr">
<div class="m-header">
<span class="m-brand">Redefined Designs</span>
<span class="m-icons"><span class="m-ico"></span><span class="m-ico">🛒</span><span class="m-ico"></span></span>
</div>
<div class="m-filterbtn">⚙ Filters <span class="m-count">3</span></div>
<div class="m-chips">
<span class="m-chip">Coffee Tables ×</span>
<span class="m-chip">vintage ×</span>
<span class="m-chip">$120$800 ×</span>
<span class="m-chip clear">Clear all</span>
</div>
<div class="m-scroll">
<div class="m-grid">
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span>
<div class="m-tagrow"><span class="m-tag magenta">vintage</span><span class="m-tag green">oak</span></div></div></div>
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span>
<div class="m-tagrow"><span class="m-tag magenta">vintage</span></div></div></div>
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Walnut low</b><span>$690</span>
<div class="m-tagrow"><span class="m-tag magenta">vintage</span><span class="m-tag blue">rare</span></div></div></div>
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Teak round</b><span>$275</span>
<div class="m-tagrow"><span class="m-tag magenta">vintage</span></div></div></div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 2. SIDE DRAWER ===== -->
<div class="mb-col pick" data-choice="side" onclick="toggleSelect(this)">
<div class="mb-cap">C1 — Side drawer</div>
<div class="mb-sub">Slides in from the right, near full-screen. Reads as "a page of filters".</div>
<div class="phone">
<div class="phone-notch"></div>
<div class="scr">
<div class="m-header">
<span class="m-brand">Redefined Designs</span>
<span class="m-icons"><span class="m-ico"></span><span class="m-ico">🛒</span><span class="m-ico"></span></span>
</div>
<div class="m-scroll" style="margin-top:8px;">
<div class="m-grid">
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span></div></div>
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span></div></div>
</div>
</div>
<div class="m-scrim"></div>
<div class="m-sheet-right">
<div class="m-sheet-head"><span>Filters</span><span></span></div>
<div class="m-sheet-body">
<div class="m-lab">Category</div>
<div class="m-tree">
<div class="muted">All items</div>
<div>▾ Furniture</div>
<div>&nbsp;&nbsp;▾ Tables</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span class="on">Coffee Tables</span></div>
<div class="muted">&nbsp;&nbsp;&nbsp;&nbsp;Side Tables</div>
<div class="muted">▸ Decor</div>
</div>
<div class="m-lab">Tags — must have all</div>
<div class="m-pills">
<span class="m-pill magenta">vintage ✓</span>
<span class="m-pill off">handmade</span>
<span class="m-pill off">oak</span>
<span class="m-pill off">restored</span>
</div>
<div class="m-lab">Price</div>
<div class="m-slider">
<div class="m-track"><div class="m-fill"></div>
<div class="m-knob" style="left:18%"></div><div class="m-knob" style="left:calc(72% - 14px)"></div>
</div>
<div class="m-range"><span>$120</span><span>$800</span></div>
</div>
</div>
<div class="m-sheet-foot">
<div class="m-foot-btn">Clear all</div>
<div class="m-foot-btn primary">Show 12 items</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 3. BOTTOM SHEET ===== -->
<div class="mb-col pick" data-choice="bottom" onclick="toggleSelect(this)">
<div class="mb-cap">C2 — Bottom sheet</div>
<div class="mb-sub">Rises from the bottom, ~78% height. Controls sit within thumb reach; grid stays partly visible.</div>
<div class="phone">
<div class="phone-notch"></div>
<div class="scr">
<div class="m-header">
<span class="m-brand">Redefined Designs</span>
<span class="m-icons"><span class="m-ico"></span><span class="m-ico">🛒</span><span class="m-ico"></span></span>
</div>
<div class="m-filterbtn">⚙ Filters <span class="m-count">3</span></div>
<div class="m-scroll" style="margin-top:8px;">
<div class="m-grid">
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span></div></div>
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span></div></div>
</div>
</div>
<div class="m-scrim"></div>
<div class="m-sheet-bottom">
<div class="m-grab"></div>
<div class="m-sheet-head"><span>Filters</span><span></span></div>
<div class="m-sheet-body">
<div class="m-lab">Category</div>
<div class="m-tree">
<div class="muted">All items</div>
<div>▾ Furniture &nbsp;▸ Tables</div>
<div>&nbsp;&nbsp;<span class="on">Coffee Tables</span></div>
<div class="muted">▸ Decor</div>
</div>
<div class="m-lab">Tags — must have all</div>
<div class="m-pills">
<span class="m-pill magenta">vintage ✓</span>
<span class="m-pill off">handmade</span>
<span class="m-pill off">oak</span>
</div>
<div class="m-lab">Price</div>
<div class="m-slider">
<div class="m-track"><div class="m-fill"></div>
<div class="m-knob" style="left:18%"></div><div class="m-knob" style="left:calc(72% - 14px)"></div>
</div>
<div class="m-range"><span>$120</span><span>$800</span></div>
</div>
</div>
<div class="m-sheet-foot">
<div class="m-foot-btn">Clear all</div>
<div class="m-foot-btn primary">Show 12 items</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:26px;">
<p class="label">What carries over from desktop</p>
<p style="font-size:14px;line-height:1.7;opacity:.88;">
Identical component tree at both sizes — the drawer just changes <code>placement</code> and
<code>width</code>/<code>height</code> at the antd <code>md</code> breakpoint, so there is no second
implementation to keep in sync. The chip row is the only piece that behaves differently: on desktop it
sits inline next to the Filters button, on mobile it wraps onto its own line beneath it.
</p>
<p style="font-size:14px;line-height:1.7;opacity:.88;">
<b>Tags now confirmed as AND</b> — an item must carry <i>every</i> selected tag to show up. The panel
labels this explicitly ("must have all") so a user who selects two tags and sees the grid shrink to
nothing understands why.
</p>
</div>
<script>
// The original frame recorded clicks back to the tool. Here it only toggles
// the highlight, so the archived mockup stays explorable offline.
function toggleSelect(el) {
var multi = el.parentElement && el.parentElement.hasAttribute('data-multiselect');
if (!multi) {
Array.prototype.forEach.call(
el.parentElement.children,
function (sibling) { if (sibling !== el) sibling.classList.remove('selected'); }
);
}
el.classList.toggle('selected');
}
</script>
</body>
</html>
@@ -0,0 +1,394 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Storefront filter layout options</title>
<!--
Archived design mockup from the brainstorming session for issue #23.
Originally a content fragment served inside a tool-provided frame; the
frame's tokens and helper are inlined below so the file opens standalone
in any browser with no server and no network access.
-->
<style>
:root {
--border: #d9d9d9;
--bg: #ffffff;
--bg-elevated: #ffffff;
--text: #1a1a1a;
--accent: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--border: #3a3a3a;
--bg: #141414;
--bg-elevated: #1d1d1d;
--text: #e8e8e8;
--accent: #60a5fa;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
max-width: 1400px;
margin-inline: auto;
}
h2 { margin: 0 0 8px; font-size: 24px; }
h3 { margin: 0 0 6px; font-size: 16px; }
h4 { margin: 0; }
.subtitle { margin: 0 0 24px; opacity: .75; font-size: 15px; }
.section { margin-bottom: 24px; }
.label {
font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
opacity: .6; font-weight: 700; margin: 0 0 6px;
}
code {
background: rgba(128,128,128,.14); padding: 1px 5px;
border-radius: 4px; font-size: .9em;
}
.archive-note {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 6px; padding: 12px 16px; margin-bottom: 28px;
font-size: 14px; background: var(--bg-elevated);
}
</style>
</head>
<body>
<div class="archive-note">
<strong>Archived mockup — Storefront filter layout options.</strong>
Produced while designing
<a href="https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23">issue #23</a>.
The option marked as chosen is recorded in
<a href="../2026-08-17-categories-and-tags-design.md">the design spec</a>.
</div>
<style>
.fl-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; align-items: start; }
@media (max-width: 1100px) { .fl-grid { grid-template-columns: 1fr; } }
.fl-card {
border: 2px solid var(--border, #d0d0d0);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color .15s, transform .15s;
background: var(--bg-elevated, #fff);
}
.fl-card:hover { transform: translateY(-2px); }
.fl-card.selected { border-color: var(--accent, #3b82f6); }
.fl-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border, #d0d0d0);
display: flex; align-items: center; gap: 10px;
font-weight: 600;
}
.fl-badge {
width: 22px; height: 22px; flex: 0 0 22px;
border-radius: 50%;
background: var(--accent, #3b82f6); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
}
.fl-body { padding: 12px; }
.fl-note { padding: 0 14px 14px; font-size: 13px; line-height: 1.5; opacity: .85; }
/* --- wireframe primitives, scaled small so 3 fit side by side --- */
.wf { border: 1px solid var(--border, #ccc); border-radius: 6px; overflow: hidden; font-size: 10px; }
.wf-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 8px; border-bottom: 1px solid var(--border, #ccc);
background: rgba(128,128,128,.10);
}
.wf-brand { font-weight: 700; font-size: 11px; }
.wf-actions { display: flex; gap: 4px; }
.wf-btn {
border: 1px solid var(--border, #ccc); border-radius: 4px;
padding: 2px 6px; font-size: 9px; background: rgba(128,128,128,.06);
white-space: nowrap;
}
.wf-btn.solid { background: #555; color: #fff; border-color: #555; }
.wf-main { display: flex; gap: 8px; padding: 8px; }
.wf-rail { flex: 0 0 33%; border-right: 1px dashed var(--border, #ccc); padding-right: 8px; }
.wf-section-label {
font-size: 8px; letter-spacing: .06em; text-transform: uppercase;
opacity: .6; margin: 8px 0 4px; font-weight: 700;
}
.wf-section-label:first-child { margin-top: 0; }
.wf-tree { line-height: 1.7; }
.wf-tree div { white-space: nowrap; }
.wf-tree .on { font-weight: 700; background: rgba(59,130,246,.18); border-radius: 3px; padding: 0 3px; }
.wf-tree .muted { opacity: .6; }
.wf-pills { display: flex; flex-wrap: wrap; gap: 3px; }
.wf-pill {
border-radius: 9px; padding: 1px 6px; font-size: 9px;
border: 1px solid transparent; white-space: nowrap;
}
.wf-pill.off { background: rgba(128,128,128,.12); border-color: var(--border, #ccc); opacity: .75; }
.wf-pill.magenta { background: #c41d7f; color: #fff; }
.wf-pill.green { background: #389e0d; color: #fff; }
.wf-pill.blue { background: #096dd9; color: #fff; }
.wf-pill.orange { background: #d46b08; color: #fff; }
.wf-slider { padding: 6px 2px 2px; }
.wf-track { height: 3px; background: rgba(128,128,128,.3); border-radius: 2px; position: relative; }
.wf-fill { position: absolute; left: 20%; right: 30%; top: 0; bottom: 0; background: var(--accent, #3b82f6); border-radius: 2px; }
.wf-knob { position: absolute; top: -3px; width: 9px; height: 9px; border-radius: 50%; background: #fff; border: 2px solid var(--accent, #3b82f6); }
.wf-range { display: flex; justify-content: space-between; font-size: 9px; margin-top: 5px; opacity: .8; }
.wf-grid { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; align-content: start; }
.wf-grid.wide { grid-template-columns: repeat(4, 1fr); }
.wf-item { border: 1px solid var(--border, #ccc); border-radius: 4px; overflow: hidden; }
.wf-photo {
height: 34px; background: repeating-linear-gradient(45deg, rgba(128,128,128,.13) 0 5px, rgba(128,128,128,.05) 5px 10px);
}
.wf-meta { padding: 3px 4px; font-size: 8px; line-height: 1.4; }
.wf-meta b { display: block; font-weight: 600; }
.wf-meta span { opacity: .7; }
.wf-tagrow { display: flex; gap: 2px; margin-top: 2px; }
.wf-dot { width: 5px; height: 5px; border-radius: 50%; }
.wf-bar {
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
padding: 7px 8px; border-bottom: 1px solid var(--border, #ccc);
}
.wf-select {
border: 1px solid var(--border, #ccc); border-radius: 4px;
padding: 3px 6px; font-size: 9px; background: rgba(128,128,128,.06);
display: flex; align-items: center; gap: 5px; white-space: nowrap;
}
.wf-caret { opacity: .5; font-size: 8px; }
.wf-chiprow { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 6px 8px; border-bottom: 1px dashed var(--border, #ccc); }
.wf-chip {
border: 1px solid var(--accent, #3b82f6); color: var(--accent, #3b82f6);
border-radius: 9px; padding: 1px 6px; font-size: 9px; white-space: nowrap;
}
.wf-drawer {
position: absolute; top: 0; right: 0; bottom: 0; width: 44%;
background: var(--bg-elevated, #fff);
border-left: 2px solid var(--accent, #3b82f6);
box-shadow: -6px 0 14px rgba(0,0,0,.18);
padding: 8px;
}
.wf-scrim { position: absolute; inset: 0; background: rgba(0,0,0,.22); }
.wf-rel { position: relative; }
</style>
<h2>Storefront filter layout — which shape fits?</h2>
<p class="subtitle">
Three filters land on the storefront: <b>category</b> (a tree), <b>tags</b> (multi-select, color-coded),
and <b>price range</b>. They differ mainly in how much screen they take from the item grid, and how well
they survive on a phone. Click the one you prefer.
</p>
<div class="fl-grid">
<!-- ============ A: SIDEBAR RAIL ============ -->
<div class="fl-card" data-choice="a" onclick="toggleSelect(this)">
<div class="fl-head"><span class="fl-badge">A</span> Sidebar filter rail</div>
<div class="fl-body">
<div class="wf">
<div class="wf-header">
<span class="wf-brand">Redefined Designs</span>
<span class="wf-actions">
<span class="wf-btn">Dark</span><span class="wf-btn">Cart</span><span class="wf-btn solid">Sign up</span>
</span>
</div>
<div class="wf-main">
<div class="wf-rail">
<div class="wf-section-label">Category</div>
<div class="wf-tree">
<div class="muted">All items</div>
<div>▾ Furniture</div>
<div>&nbsp;&nbsp;▾ Tables</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span class="on">Coffee</span></div>
<div class="muted">&nbsp;&nbsp;&nbsp;&nbsp;Side</div>
<div class="muted">▸ Decor</div>
</div>
<div class="wf-section-label">Tags</div>
<div class="wf-pills">
<span class="wf-pill magenta">vintage</span>
<span class="wf-pill off">handmade</span>
<span class="wf-pill off">oak</span>
<span class="wf-pill off">restored</span>
</div>
<div class="wf-section-label">Price</div>
<div class="wf-slider">
<div class="wf-track"><div class="wf-fill"></div>
<div class="wf-knob" style="left:20%"></div><div class="wf-knob" style="left:calc(70% - 9px)"></div>
</div>
<div class="wf-range"><span>$120</span><span>$800</span></div>
</div>
</div>
<div class="wf-grid">
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#096dd9"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#d46b08"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
</div>
</div>
</div>
</div>
<div class="fl-note">
Everything visible at once — the tree reads as actual folders, tag colors are on display, no clicking to
discover what's filterable. Costs ~25% of the grid width, so cards drop from 4 to 3 per row on desktop.
On mobile the rail has to collapse into an accordion above the grid.
</div>
</div>
<!-- ============ B: HORIZONTAL BAR ============ -->
<div class="fl-card" data-choice="b" onclick="toggleSelect(this)">
<div class="fl-head"><span class="fl-badge">B</span> Horizontal filter bar</div>
<div class="fl-body">
<div class="wf">
<div class="wf-header">
<span class="wf-brand">Redefined Designs</span>
<span class="wf-actions">
<span class="wf-btn">Dark</span><span class="wf-btn">Cart</span><span class="wf-btn solid">Sign up</span>
</span>
</div>
<div class="wf-bar">
<span class="wf-select">Furniture / Tables / Coffee <span class="wf-caret"></span></span>
<span class="wf-select">
<span class="wf-pill magenta">vintage</span><span class="wf-pill green">oak</span>
<span class="wf-caret"></span>
</span>
<span class="wf-select">$120 $800 <span class="wf-caret"></span></span>
<span class="wf-btn">Clear</span>
</div>
<div class="wf-main">
<div class="wf-grid wide">
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#096dd9"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#d46b08"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Cedar</b><span>$230</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Maple</b><span>$610</span>
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
</div>
</div>
</div>
</div>
<div class="fl-note">
Three compact controls in one row — an antd <i>Cascader</i> for the category tree, a tag multi-select,
and a price-range dropdown. Full grid width is preserved (4 cards per row), and the same row wraps
naturally on a phone. The tree is only visible once you open the cascader, so the category structure
is less discoverable.
</div>
</div>
<!-- ============ C: DRAWER + CHIPS ============ -->
<div class="fl-card" data-choice="c" onclick="toggleSelect(this)">
<div class="fl-head"><span class="fl-badge">C</span> Drawer + active-filter chips</div>
<div class="fl-body">
<div class="wf wf-rel">
<div class="wf-header">
<span class="wf-brand">Redefined Designs</span>
<span class="wf-actions">
<span class="wf-btn solid">Filters (3)</span><span class="wf-btn">Cart</span>
</span>
</div>
<div class="wf-chiprow">
<span class="wf-chip">Coffee Tables ×</span>
<span class="wf-chip">vintage ×</span>
<span class="wf-chip">$120$800 ×</span>
</div>
<div class="wf-main">
<div class="wf-grid wide">
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Cedar</b><span>$230</span></div></div>
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Maple</b><span>$610</span></div></div>
</div>
</div>
<div class="wf-scrim"></div>
<div class="wf-drawer">
<div style="font-weight:700;font-size:11px;margin-bottom:6px;">Filters</div>
<div class="wf-section-label">Category</div>
<div class="wf-tree">
<div class="muted">All items</div>
<div>▾ Furniture</div>
<div>&nbsp;&nbsp;▾ Tables</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span class="on">Coffee</span></div>
<div class="muted">▸ Decor</div>
</div>
<div class="wf-section-label">Tags</div>
<div class="wf-pills">
<span class="wf-pill magenta">vintage</span>
<span class="wf-pill off">handmade</span>
<span class="wf-pill off">oak</span>
</div>
<div class="wf-section-label">Price</div>
<div class="wf-slider">
<div class="wf-track"><div class="wf-fill"></div>
<div class="wf-knob" style="left:20%"></div><div class="wf-knob" style="left:calc(70% - 9px)"></div>
</div>
<div class="wf-range"><span>$120</span><span>$800</span></div>
</div>
</div>
</div>
</div>
<div class="fl-note">
A "Filters" button opens a slide-over panel holding the full tree, tag pills, and price slider; what's
currently applied stays visible as removable chips above the grid. Full grid width, and identical
behaviour on desktop and mobile. Trade-off: filtering is a two-step interaction, and nothing hints at
the category structure until you open the drawer.
</div>
</div>
</div>
<div class="section" style="margin-top:22px;">
<p class="label">Same for all three</p>
<p style="font-size:14px;line-height:1.6;opacity:.85;">
Filters combine with AND (category <b>and</b> tags <b>and</b> price). Multiple selected tags are the one
open sub-question — "has <i>any</i> of these tags" vs "has <i>all</i> of them" — I'll ask that next.
Filtering happens server-side via query params on <code>/api/items</code>, so the result set stays correct
no matter how many items exist.
</p>
</div>
<script>
// The original frame recorded clicks back to the tool. Here it only toggles
// the highlight, so the archived mockup stays explorable offline.
function toggleSelect(el) {
var multi = el.parentElement && el.parentElement.hasAttribute('data-multiselect');
if (!multi) {
Array.prototype.forEach.call(
el.parentElement.children,
function (sibling) { if (sibling !== el) sibling.classList.remove('selected'); }
);
}
el.classList.toggle('selected');
}
</script>
</body>
</html>
+6 -1
View File
@@ -7,7 +7,12 @@ export default defineConfig({
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
trace: 'on-first-retry',
// The app turns off antd's transitions under this preference. Animated
// popups never settle long enough for Playwright's stability check when
// the machine is loaded, which showed up as clicks timing out on a button
// that was plainly visible and enabled.
reducedMotion: 'reduce'
},
webServer: {
command: 'npm run dev',
+20 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
import { Link, useSearchParams } from 'react-router-dom';
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
@@ -27,6 +27,7 @@ const FILTER_DEBOUNCE_MS = 250;
export default function App() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [options, setOptions] = useState<FilterOptions | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
@@ -55,7 +56,14 @@ export default function App() {
const load = useCallback(() => {
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
.then(setItems)
.then((loaded) => {
setItems(loaded);
setFailed(false);
})
// A failed request must never fall through to the empty state: telling a
// customer "no items yet" when the server is broken hides the outage and
// reads as an empty shop.
.catch(() => setFailed(true))
.finally(() => setLoading(false));
}, [filterKey]);
@@ -124,8 +132,16 @@ export default function App() {
/>
</div>
{loading && !items.length ? <Spin /> : null}
{!loading && !items.length ? (
{loading && !items.length && !failed ? <Spin /> : null}
{failed ? (
<Alert
type="error"
showIcon
message="Couldn't load items"
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
/>
) : !loading && !items.length ? (
<Empty
description={
hasActiveFilters(filters)
+41 -26
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
TreeSelect, Select
Select
} from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -14,26 +14,12 @@ import {
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
fetchAdminCategories, fetchAdminTags
} from '../api';
import { buildCategoryTree, CategoryNode } from '../filters';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
interface CategoryTreeOption {
value: number;
title: string;
children?: CategoryTreeOption[];
}
function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
return nodes.map(node => ({
value: node.id,
title: node.name,
children: node.children.length ? toCategoryTreeData(node.children) : undefined
}));
}
import CategoryTreeSelect from './CategoryTreeSelect';
const { Header, Content } = Layout;
const { Title } = Typography;
@@ -47,6 +33,7 @@ function Inventory() {
const [description, setDescription] = useState<string>('');
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<TagRecord[]>([]);
const [saving, setSaving] = useState(false);
const { mode } = useThemeMode();
const load = () => fetchAdminItems().then(setItems);
@@ -94,21 +81,55 @@ function Inventory() {
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
fd.append('tags', JSON.stringify(values.tags ?? []));
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
// Only report success, close the form, and discard the entered values once
// the server has actually accepted the write.
setSaving(true);
try {
await saveItem(editingItem?.id ?? null, fd);
} catch (err) {
message.error(`Couldn't save item — ${(err as Error).message}`);
return;
} finally {
setSaving(false);
}
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
load();
loadOptions();
}
// Status changes silently did nothing on failure — the row simply stayed put
// with no indication why.
async function handleStatusChange(action: (id: number) => Promise<Item>, id: number, label: string) {
try {
await action(id);
} catch (err) {
message.error(`Couldn't ${label}${(err as Error).message}`);
return;
}
load();
}
async function handleDelete(id: number) {
try {
await deleteItem(id);
} catch (err) {
message.error(`Couldn't delete item — ${(err as Error).message}`);
return;
}
message.success('Item deleted');
load();
}
async function handleDeleteImage(itemId: number, imageId: number) {
try {
await deleteItemImage(itemId, imageId);
} catch (err) {
message.error(`Couldn't remove image — ${(err as Error).message}`);
return;
}
message.success('Image removed');
load();
setEditingItem(prev => prev && prev.id === itemId
@@ -159,8 +180,8 @@ function Inventory() {
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
{item.status !== 'sold'
? <Button size="small" onClick={() => markSold(item.id).then(load)}>Mark Sold</Button>
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>Mark Available</Button>}
? <Button size="small" onClick={() => handleStatusChange(markSold, item.id, 'mark sold')}>Mark Sold</Button>
: <Button size="small" onClick={() => handleStatusChange(markAvailable, item.id, 'mark available')}>Mark Available</Button>}
</Space>
)
}
@@ -174,7 +195,7 @@ function Inventory() {
</div>
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose width={720}>
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
<div data-color-mode={mode}>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
@@ -187,13 +208,7 @@ function Inventory() {
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="category_id" label="Category">
<TreeSelect
allowClear
placeholder="Uncategorized"
treeDefaultExpandAll
treeData={toCategoryTreeData(buildCategoryTree(categories))}
style={{ width: '100%' }}
/>
<CategoryTreeSelect categories={categories} onCategoriesChanged={setCategories} />
</Form.Item>
<Form.Item
name="tags"
+107
View File
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react';
import TreeSelect from 'antd/lib/tree-select';
import Input from 'antd/lib/input';
import Button from 'antd/lib/button';
import Divider from 'antd/lib/divider';
import message from 'antd/lib/message';
import { Category, createCategory, fetchAdminCategories } from '../api';
import { buildCategoryTree, CategoryNode } from '../filters';
interface CategoryTreeOption {
value: number;
title: string;
children?: CategoryTreeOption[];
}
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
return nodes.map((node) => ({
value: node.id,
title: node.name,
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
interface Props {
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
// its association with the rendered <label>, which breaks both screen readers
// and any lookup by label.
value?: number;
onChange?: (value: number | undefined) => void;
id?: string;
categories: Category[];
onCategoriesChanged: (categories: Category[]) => void;
}
// Pulled out of the item form so that typing a new category name re-renders
// only this control. Left inline, every keystroke re-rendered the whole
// Inventory component and rebuilt the tree data, which visibly jittered the
// open popup and moved the buttons under the pointer.
export default function CategoryTreeSelect({ value, onChange, id, categories, onCategoriesChanged }: Props) {
const [newCategoryName, setNewCategoryName] = useState('');
const [creating, setCreating] = useState(false);
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
// Tags can be invented from the item form, so categories should be too —
// otherwise adding an item in a new category means abandoning a half-filled
// form. New categories land at the top level; nesting is done in the
// Categories tab, keeping this control to a single decision.
async function handleCreate() {
const name = newCategoryName.trim();
if (!name) return;
setCreating(true);
try {
const created = await createCategory(name, null);
onCategoriesChanged(await fetchAdminCategories());
onChange?.(created.id);
setNewCategoryName('');
message.success(`Category "${created.name}" added`);
} catch (err) {
message.error(`Couldn't create category — ${(err as Error).message}`);
} finally {
setCreating(false);
}
}
return (
<TreeSelect
allowClear
showSearch
placeholder="Uncategorized"
// Deliberately not treeDefaultExpandAll: expanding a large tree on open
// makes the popup reflow while it measures, and buries the create field
// below every row. Search is the better affordance past a screenful.
treeNodeFilterProp="title"
listHeight={256}
treeData={treeData}
id={id}
value={value}
onChange={onChange}
style={{ width: '100%' }}
popupRender={(menu) => (
<>
{/* Above the tree, not below it. Under a long list the control is
both invisible without scrolling and positioned by the list's
measured height, so it shifts as the virtualized rows settle. */}
<div style={{ display: 'flex', gap: 8, padding: '8px 8px 0' }}>
<Input
placeholder="New category name"
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
// Without this the tree steals the keystrokes for its own
// type-ahead and arrow-key navigation.
onKeyDown={(event) => event.stopPropagation()}
onPressEnter={handleCreate}
/>
<Button type="primary" loading={creating} onClick={handleCreate}>
Create category
</Button>
</div>
<Divider style={{ margin: '8px 0' }} />
{menu}
</>
)}
/>
);
}
+33 -6
View File
@@ -54,40 +54,67 @@ export async function fetchConfig(): Promise<SiteConfig> {
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
const query = filters ? filtersToSearchParams(filters).toString() : '';
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
// An error response still parses as JSON — as `{ error: ... }`, not an array.
// Returning that unchecked would set it as the item list and crash the grid
// on `.map`, so a failure has to surface as a rejection the caller can show.
if (!res.ok) throw new Error('failed to load items');
return res.json();
}
export async function fetchFilterOptions(): Promise<FilterOptions> {
const res = await fetch('/api/filters');
if (!res.ok) throw new Error('failed to load filters');
return res.json();
}
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
// resolves — the caller then reports success for a write that never happened,
// which is worse than failing outright because nothing prompts the user to look
// for the missing row.
async function expectOk(res: Response, action: string): Promise<Response> {
if (res.ok) return res;
const detail = await res.json().catch(() => null);
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
}
export async function fetchAdminItems(): Promise<Item[]> {
const res = await fetch('/api/admin/items');
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items');
return res.json();
}
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
const res = await fetch(url, { method: id ? 'PUT' : 'POST', body: formData });
const res = await expectOk(
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
'failed to save item'
);
return res.json();
}
export async function deleteItem(id: number): Promise<void> {
await fetch(`/api/admin/items/${id}`, { method: 'DELETE' });
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
}
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' });
await expectOk(
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
'failed to remove image'
);
}
export async function markSold(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' });
const res = await expectOk(
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
'failed to mark sold'
);
return res.json();
}
export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
const res = await expectOk(
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
'failed to mark available'
);
return res.json();
}
+24 -2
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme } from 'antd';
@@ -16,13 +16,35 @@ import { CartProvider } from './cart/CartContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
// standing source of flake in end-to-end tests, which drive the app with this
// preference enabled.
function usePrefersReducedMotion(): boolean {
const [prefers, setPrefers] = useState(
() => typeof window !== 'undefined' && window.matchMedia(REDUCED_MOTION_QUERY).matches
);
useEffect(() => {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
const update = () => setPrefers(query.matches);
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return prefers;
}
function Root() {
const { mode } = useThemeMode();
const prefersReducedMotion = usePrefersReducedMotion();
return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: '#1a1a1a' }
token: { colorPrimary: '#1a1a1a', motion: !prefersReducedMotion }
}}
>
<BrowserRouter>
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
const suffix = () => `i${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Opening the form kicks off a categories fetch. Interacting with the category
// field before it lands means the tree re-renders under the cursor, so wait for
// the data rather than racing it.
async function openItemForm(page: import('@playwright/test').Page) {
// Opening the form fetches both categories and tags, and each one re-renders
// the modal as it lands. Waiting for only one still leaves the second to
// reflow the popup mid-interaction.
const loaded = Promise.all([
page.waitForResponse((res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'),
page.waitForResponse((res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET')
]);
await page.getByRole('button', { name: 'Add Item' }).click();
await loaded;
}
test.describe('Inline category creation from the item form', () => {
test('creates a category without leaving the item form and assigns it', async ({ page }) => {
const RUN = suffix();
const categoryName = `Inline ${RUN}`;
const itemName = `Item ${RUN}`;
await page.goto('/admin');
await openItemForm(page);
await page.getByLabel('Name').fill(itemName);
await page.getByLabel('Price (USD)').fill('99');
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const nameInput = page.getByPlaceholder('New category name');
await expect(nameInput).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter');
// The new category should be selected straight away — having to hunt for it
// in the tree afterwards defeats the point of creating it inline.
await expect(page.getByRole('dialog').getByText(categoryName)).toBeVisible();
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Item added')).toBeVisible();
const items = await (await page.request.get('/api/admin/items')).json();
const saved = items.find((item: { name: string }) => item.name === itemName);
expect(saved).toBeTruthy();
expect(saved.category_name).toBe(categoryName);
});
test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => {
const RUN = suffix();
const categoryName = `Dupe ${RUN}`;
const created = await page.request.post('/api/admin/categories', {
data: { name: categoryName, parent_id: null }
});
expect(created.status()).toBe(201);
await page.goto('/admin');
await openItemForm(page);
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const nameInput = page.getByPlaceholder('New category name');
await expect(nameInput).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter');
await expect(page.getByText(/already exists/i)).toBeVisible();
});
});
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
test.describe('Admin save failures', () => {
test('does not claim an item was saved when the request failed', async ({ page }) => {
await page.route('**/api/admin/items', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 500,
contentType: 'application/json',
body: '{"error":"internal error"}'
});
}
return route.continue();
});
await page.goto('/admin');
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Name').fill(`Broken ${suffix()}`);
await page.getByLabel('Price (USD)').fill('12');
await page.getByRole('button', { name: 'OK' }).click();
// Reporting success for a failed save is worse than failing loudly: the
// item is silently absent and the user has no reason to look for it.
await expect(page.getByText('Item added')).toBeHidden();
await expect(page.getByText("Couldn't save item")).toBeVisible();
// The form must stay open so the entered values aren't lost.
await expect(page.getByRole('dialog')).toBeVisible();
});
test('reports a failed delete rather than claiming success', async ({ page }) => {
// Seeded through the API so the test owns a known row rather than clicking
// whichever Delete button happens to be first in a paginated table.
const name = `Doomed ${suffix()}`;
const created = await page.request.post('/api/admin/items', {
multipart: { name, description: '', price: '10', category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
await page.route('**/api/admin/items/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' });
}
return route.continue();
});
await page.goto('/admin');
// Items list newest-first, so the seeded row is on the first page.
const row = page.getByRole('row').filter({ hasText: name });
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeHidden();
await expect(page.getByText("Couldn't delete item")).toBeVisible();
// The row must survive a failed delete.
await expect(row).toBeVisible();
});
test('saves an item successfully when the server accepts it', async ({ page }) => {
const name = `Good ${suffix()}`;
await page.goto('/admin');
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Name').fill(name);
await page.getByLabel('Price (USD)').fill('34');
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Item added')).toBeVisible();
// Confirm the row actually reached the database, not just that a toast fired.
const items = await (await page.request.get('/api/admin/items')).json();
expect(items.some((item: { name: string }) => item.name === name)).toBeTruthy();
});
});
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';
test.describe('Storefront failure states', () => {
test('reports a server failure instead of claiming the store is empty', async ({ page }) => {
await page.route('**/api/items*', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
);
await page.goto('/');
// Telling a customer "no items yet" when the server is broken is worse than
// saying nothing — it reads as an empty catalogue and hides the outage.
await expect(page.getByText('No items yet')).toBeHidden();
await expect(page.getByText("Couldn't load items")).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('recovers when the server comes back', async ({ page }) => {
let failing = true;
await page.route('**/api/items*', (route) => {
if (failing) {
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' });
}
return route.continue();
});
await page.goto('/');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
failing = false;
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText("Couldn't load items")).toBeHidden();
});
test('a request that never resolves does not render as an empty catalogue', async ({ page }) => {
// Mirrors the real incident: an un-migrated database left every item query
// hanging with no response at all.
await page.route('**/api/items*', () => { /* never fulfilled */ });
await page.goto('/');
await expect(page.getByText('No items yet')).toBeHidden();
});
});