diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 0000000..5d764bd
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,23 @@
+name: Quality
+
+on:
+ push:
+ branches: [develop]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm test
+ - run: npm run test --workspace=@pokemon-inventory/web
+ - run: npm run build --workspace=@pokemon-inventory/web
+ - run: npm run build-storybook --workspace=@pokemon-inventory/web
+ - run: npm run test-storybook
\ No newline at end of file
diff --git a/README.md b/README.md
index 306f71c..a0a0743 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,14 @@ npm run storybook
Open `http://localhost:6006/`. Use the Storybook theme toolbar to switch between light and dark themes. The accessibility addon is enabled for component review. The fixture showcase is intentionally mock-only and is not part of the production router.
+Seed representative API-backed catalog data for browser testing with:
+
+```sh
+npm run seed:demo
+```
+
+The seed is non-destructive and exits without changes when the demo catalog already exists.
+
## Initial Administrator
After the development database is migrated, set `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and `INITIAL_SELLER_NAME` in your local `.env`, then run `npm run provision:admin` once. The command creates the only initial administrator and linked Seller; it refuses to run after any User exists.
diff --git a/apps/api/package.json b/apps/api/package.json
index 5b18517..ca058cc 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
+ "seed:demo": "node src/modules/catalog/scripts/seed-demo-catalog.js",
"provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js",
"start": "node src/app/server.js",
"test:application": "node --test tests/application/*.test.js",
diff --git a/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js b/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js
new file mode 100644
index 0000000..58c2c7b
--- /dev/null
+++ b/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js
@@ -0,0 +1,101 @@
+import dotenv from 'dotenv';
+import { fileURLToPath } from 'node:url';
+import { eq } from 'drizzle-orm';
+
+import { createDatabase } from '../../../infrastructure/database/client.js';
+import {
+ categories,
+ conditionProfiles,
+ conditions,
+ inventoryItems,
+ listings,
+ products,
+ sellers,
+ users,
+} from '../../../infrastructure/database/schema.js';
+
+dotenv.config({ path: fileURLToPath(new URL('../../../../../../.env', import.meta.url)) });
+
+const demoEmail = 'demo-catalog@example.test';
+
+async function seedDemoCatalog() {
+ const database = createDatabase();
+ const [existingUser] = await database.db.select().from(users).where(eq(users.email, demoEmail));
+
+ if (existingUser) {
+ console.log(`Demo catalog already exists for ${demoEmail}.`);
+ await database.close();
+ return;
+ }
+
+ try {
+ await database.db.transaction(async (tx) => {
+ const [user] = await tx.insert(users).values({
+ email: demoEmail,
+ passwordHash: 'demo-catalog-password-not-for-login',
+ }).returning();
+ const [seller] = await tx.insert(sellers).values({
+ userId: user.id,
+ displayName: 'Demo Catalog',
+ }).returning();
+ const [category] = await tx.insert(categories).values({
+ name: 'Demo Collectibles',
+ slug: 'demo-collectibles',
+ }).returning();
+ const [profile] = await tx.insert(conditionProfiles).values({
+ categoryId: category.id,
+ name: 'Collectible Condition',
+ }).returning();
+ const [condition] = await tx.insert(conditions).values({
+ conditionProfileId: profile.id,
+ name: 'Near Mint',
+ code: 'NM',
+ qualityRank: 1,
+ }).returning();
+
+ const fixtures = [
+ { name: 'Charizard ex #199/165', slug: 'charizard-ex-199-165', price: 11500, status: 'AVAILABLE' },
+ { name: 'Pikachu World Collection', slug: 'pikachu-world-collection', price: 8200, status: 'PENDING' },
+ { name: 'Gengar holo promo', slug: 'gengar-holo-promo', price: 4500, status: 'CLOSED', inventoryStatus: 'SOLD' },
+ ];
+
+ for (const fixture of fixtures) {
+ const [product] = await tx.insert(products).values({
+ sellerId: seller.id,
+ categoryId: category.id,
+ name: fixture.name,
+ slug: fixture.slug,
+ description: 'A representative demo collectible for local browser testing.',
+ brand: 'Demo Archive',
+ setName: 'Demo Collection',
+ cardNumber: fixture.slug === 'charizard-ex-199-165' ? '199/165' : null,
+ releaseYear: 2026,
+ }).returning();
+ const [inventoryItem] = await tx.insert(inventoryItems).values({
+ productId: product.id,
+ conditionId: condition.id,
+ status: fixture.inventoryStatus ?? 'ACTIVE',
+ notes: 'Private demo inventory note.',
+ }).returning();
+ await tx.insert(listings).values({
+ inventoryItemId: inventoryItem.id,
+ productName: product.name,
+ categoryId: category.id,
+ conditionId: condition.id,
+ askingPriceMinor: fixture.price,
+ status: fixture.status,
+ notes: fixture.status === 'CLOSED' ? 'Sold history retained.' : 'Available for local preview.',
+ });
+ }
+ });
+
+ console.log('Seeded three demo collectibles for browser testing.');
+ } finally {
+ await database.close();
+ }
+}
+
+seedDemoCatalog().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/apps/web/.storybook/preview.js b/apps/web/.storybook/preview.js
index a63f80b..8247ecf 100644
--- a/apps/web/.storybook/preview.js
+++ b/apps/web/.storybook/preview.js
@@ -21,6 +21,6 @@ export const decorators = [
export const parameters = {
controls: { expanded: true },
- a11y: { test: 'todo' },
+ a11y: { test: 'error' },
layout: 'fullscreen',
};
diff --git a/apps/web/package.json b/apps/web/package.json
index f97e514..dd9a73b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,7 @@
"build": "vite build",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+ "test-storybook": "test-storybook --url http://127.0.0.1:6006 --ci",
"test": "node --test tests/*.test.js"
},
"dependencies": {
diff --git a/apps/web/src/assets/styles/global-tokens.scss b/apps/web/src/assets/styles/global-tokens.scss
index f0df669..57bf09f 100644
--- a/apps/web/src/assets/styles/global-tokens.scss
+++ b/apps/web/src/assets/styles/global-tokens.scss
@@ -17,6 +17,10 @@
--color-warning-500: #d88a00;
--color-danger-500: #e5484d;
--color-info-500: #1683c4;
+ --color-success-text: #08783a;
+ --color-warning-text: #8a5700;
+ --color-danger-text: #b4232b;
+ --color-info-text: #0b5f91;
--font-ui: 'Manrope', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Space Grotesk', 'Manrope', ui-sans-serif, sans-serif;
@@ -77,6 +81,7 @@
--color-border: var(--color-gray-200);
--color-border-strong: var(--color-gray-300);
--color-interactive: var(--color-cobalt-500);
+ --color-interactive-text: var(--color-cobalt-700);
--color-interactive-hover: var(--color-cobalt-700);
--color-on-interactive: var(--color-white);
--color-focus: var(--color-cobalt-500);
@@ -97,6 +102,7 @@
--color-border: #2b3745;
--color-border-strong: #425064;
--color-interactive: var(--color-cobalt-400);
+ --color-interactive-text: var(--color-cobalt-400);
--color-interactive-hover: #83adff;
--color-on-interactive: #07142f;
--color-focus: var(--color-cobalt-400);
diff --git a/apps/web/src/assets/styles/global.scss b/apps/web/src/assets/styles/global.scss
index 909e7ae..a76f928 100644
--- a/apps/web/src/assets/styles/global.scss
+++ b/apps/web/src/assets/styles/global.scss
@@ -54,7 +54,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); }
.button--primary { background: var(--color-interactive); color: var(--color-on-interactive); }
.button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
-.button--danger { background: var(--color-danger-500); color: var(--color-white); }
+.button--danger { background: var(--color-danger-text); color: var(--color-white); }
.button:disabled { cursor: not-allowed; opacity: 0.45; }
.swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); }
.swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
@@ -69,11 +69,11 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.type-sample__numeric { font-size: var(--text-xl); font-variant-numeric: tabular-nums; }
.status { display: inline-flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-full); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-bold); }
.status::before { width: 8px; height: 8px; border-radius: 50%; background: currentColor; content: ''; }
-.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-500); }
-.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-500); }
-.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-500); }
+.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-text); }
+.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-text); }
+.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-text); }
.condition { border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); color: var(--color-text-subtle); font-size: var(--text-sm); }
-.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); }
+.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); }
.fixture-stage { display: grid; place-items: center; min-height: 180px; border-radius: var(--radius-lg); background: var(--color-stage); color: var(--color-text-muted); text-align: center; }
.fixture-stage--image { min-height: 240px; background: linear-gradient(135deg, var(--color-stage), var(--color-surface-muted)); }
.fixture-stage__glyph { font-family: var(--font-display); font-size: 3rem; font-weight: var(--weight-bold); }
@@ -85,8 +85,8 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.state-stack { display: grid; gap: var(--space-3); }
.state { border-radius: var(--radius-lg); padding: var(--space-4); }
.state--empty { border: var(--border-thin) dashed var(--color-border-strong); color: var(--color-text-muted); }
-.state--loading { background: var(--color-surface-muted); color: var(--color-text-muted); }
-.state--error { background: color-mix(in srgb, var(--color-danger-500) 10%, transparent); color: var(--color-danger-500); }
+.state--loading { background: var(--color-surface-muted); color: var(--color-text-subtle); }
+.state--error { background: color-mix(in srgb, var(--color-danger-500) 10%, transparent); color: var(--color-danger-text); }
@media (max-width: 768px) {
.catalog-header { padding-top: var(--space-16); }
diff --git a/apps/web/src/components/atoms/ConditionBadge.scss b/apps/web/src/components/atoms/ConditionBadge.scss
new file mode 100644
index 0000000..0628e8b
--- /dev/null
+++ b/apps/web/src/components/atoms/ConditionBadge.scss
@@ -0,0 +1,2 @@
+.condition { min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); background: var(--color-surface); color: var(--color-text-subtle); font-size: var(--text-sm); }
+.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); }
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/ConditionBadge.stories.js b/apps/web/src/components/atoms/ConditionBadge.stories.js
new file mode 100644
index 0000000..2754c72
--- /dev/null
+++ b/apps/web/src/components/atoms/ConditionBadge.stories.js
@@ -0,0 +1,5 @@
+import ConditionBadge from './ConditionBadge.vue';
+
+export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
+export const Unselected = { args: { label: 'Near Mint', selected: false } };
+export const Selected = { args: { label: 'Near Mint', selected: true } };
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/ConditionBadge.vue b/apps/web/src/components/atoms/ConditionBadge.vue
new file mode 100644
index 0000000..94ec55c
--- /dev/null
+++ b/apps/web/src/components/atoms/ConditionBadge.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/PriceDisplay.scss b/apps/web/src/components/atoms/PriceDisplay.scss
new file mode 100644
index 0000000..d1ce792
--- /dev/null
+++ b/apps/web/src/components/atoms/PriceDisplay.scss
@@ -0,0 +1,3 @@
+.price-display { display: inline-grid; gap: var(--space-1); }
+.price-display__label { color: var(--color-text-muted); font-size: var(--text-xs); }
+.price-display__value { color: var(--color-text); font-size: var(--text-lg); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/PriceDisplay.stories.js b/apps/web/src/components/atoms/PriceDisplay.stories.js
new file mode 100644
index 0000000..16d6336
--- /dev/null
+++ b/apps/web/src/components/atoms/PriceDisplay.stories.js
@@ -0,0 +1,5 @@
+import PriceDisplay from './PriceDisplay.vue';
+
+export default { title: 'Atoms/PriceDisplay', component: PriceDisplay };
+export const AskingPrice = { args: { amountMinor: 11500 } };
+export const MarketReference = { args: { amountMinor: 12000, label: 'Market reference' } };
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/PriceDisplay.vue b/apps/web/src/components/atoms/PriceDisplay.vue
new file mode 100644
index 0000000..3670bc6
--- /dev/null
+++ b/apps/web/src/components/atoms/PriceDisplay.vue
@@ -0,0 +1,17 @@
+
+
+
+
+ {{ label }}
+ ${{ (amountMinor / 100).toFixed(2) }}
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/StatusBadge.scss b/apps/web/src/components/atoms/StatusBadge.scss
new file mode 100644
index 0000000..76b846d
--- /dev/null
+++ b/apps/web/src/components/atoms/StatusBadge.scss
@@ -0,0 +1,5 @@
+.status { display: inline-flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-full); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-bold); }
+.status::before { width: 8px; height: 8px; flex: 0 0 auto; border-radius: var(--radius-full); background: currentColor; content: ''; }
+.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-text); }
+.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-text); }
+.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-text); }
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/StatusBadge.stories.js b/apps/web/src/components/atoms/StatusBadge.stories.js
new file mode 100644
index 0000000..c69ee80
--- /dev/null
+++ b/apps/web/src/components/atoms/StatusBadge.stories.js
@@ -0,0 +1,6 @@
+import StatusBadge from './StatusBadge.vue';
+
+export default { title: 'Atoms/StatusBadge', component: StatusBadge, argTypes: { status: { control: 'select', options: ['AVAILABLE', 'PENDING', 'SOLD'] } } };
+export const Available = { args: { status: 'AVAILABLE' } };
+export const Pending = { args: { status: 'PENDING' } };
+export const Sold = { args: { status: 'SOLD' } };
\ No newline at end of file
diff --git a/apps/web/src/components/atoms/StatusBadge.vue b/apps/web/src/components/atoms/StatusBadge.vue
new file mode 100644
index 0000000..88b0e86
--- /dev/null
+++ b/apps/web/src/components/atoms/StatusBadge.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ {{ status }}
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ImageGallery.scss b/apps/web/src/components/molecules/ImageGallery.scss
new file mode 100644
index 0000000..e44b591
--- /dev/null
+++ b/apps/web/src/components/molecules/ImageGallery.scss
@@ -0,0 +1,7 @@
+.image-gallery { display: grid; gap: var(--space-3); }
+.image-gallery__stage { display: grid; place-items: center; min-height: 280px; border-radius: var(--radius-xl); background: var(--color-stage); overflow: hidden; }
+.image-gallery__stage img { width: 100%; height: 100%; min-height: 280px; object-fit: contain; }
+.image-gallery__placeholder { color: var(--color-text-muted); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--weight-bold); }
+.image-gallery__controls { display: flex; gap: var(--space-2); }
+.image-gallery__controls button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); }
+.image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive); }
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ImageGallery.stories.js b/apps/web/src/components/molecules/ImageGallery.stories.js
new file mode 100644
index 0000000..0289f10
--- /dev/null
+++ b/apps/web/src/components/molecules/ImageGallery.stories.js
@@ -0,0 +1,11 @@
+import ImageGallery from './ImageGallery.vue';
+
+export default { title: 'Molecules/ImageGallery', component: ImageGallery };
+export const ContainedMuseumStage = {
+ args: {
+ images: [
+ { id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' },
+ { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
+ ],
+ },
+};
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ImageGallery.vue b/apps/web/src/components/molecules/ImageGallery.vue
new file mode 100644
index 0000000..319577e
--- /dev/null
+++ b/apps/web/src/components/molecules/ImageGallery.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
![]()
+
{{ props.images[selectedIndex]?.label ?? 'IMAGE' }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ListingCard.scss b/apps/web/src/components/molecules/ListingCard.scss
index 18e4475..9fae504 100644
--- a/apps/web/src/components/molecules/ListingCard.scss
+++ b/apps/web/src/components/molecules/ListingCard.scss
@@ -14,7 +14,7 @@
}
.listing-card:hover { border-color: var(--color-interactive); transform: translateY(-2px); }
-.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; border-radius: var(--radius-md); background: var(--color-stage); color: var(--color-interactive); font: var(--weight-bold) var(--text-sm) var(--font-display); }
+.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; border-radius: var(--radius-md); background: var(--color-stage); color: var(--color-interactive-text); font: var(--weight-bold) var(--text-sm) var(--font-display); }
.listing-card__status { display: block; margin-top: 2px; }
.listing-card__name { display: block; margin: var(--space-2) 0 var(--space-4); font-family: var(--font-display); font-size: var(--text-lg); }
.listing-card__price { display: block; font-size: var(--text-md); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ListingCard.stories.js b/apps/web/src/components/molecules/ListingCard.stories.js
index 0cf48f3..f43c395 100644
--- a/apps/web/src/components/molecules/ListingCard.stories.js
+++ b/apps/web/src/components/molecules/ListingCard.stories.js
@@ -1,4 +1,8 @@
-export default { title: 'Molecules/ListingCard' };
+import { expect, userEvent, within } from '@storybook/test';
+
+import ListingCard from './ListingCard.vue';
+
+export default { title: 'Molecules/ListingCard', component: ListingCard };
export const available = {
args: {
@@ -31,4 +35,13 @@ export const sold = {
status: 'SOLD',
},
},
+};
+
+export const KeyboardReachable = {
+ args: available.args,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.tab();
+ await expect(canvas.getByRole('link')).toHaveFocus();
+ },
};
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ListingCard.vue b/apps/web/src/components/molecules/ListingCard.vue
index 891f36f..e29f36f 100644
--- a/apps/web/src/components/molecules/ListingCard.vue
+++ b/apps/web/src/components/molecules/ListingCard.vue
@@ -1,5 +1,6 @@
-
+
PK
- {{ listing.status }}
+
{{ listing.productName }}
- ${{ (listing.askingPriceMinor / 100).toFixed(2) }}
+
-
+