feat: complete visual catalog development workflow
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 21:50:32 -04:00
parent e3ab710085
commit 9bb4a16e9e
26 changed files with 7312 additions and 26 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ export const decorators = [
export const parameters = {
controls: { expanded: true },
a11y: { test: 'todo' },
a11y: { test: 'error' },
layout: 'fullscreen',
};
+1
View File
@@ -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": {
@@ -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);
+7 -7
View File
@@ -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); }
@@ -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); }
@@ -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 } };
@@ -0,0 +1,17 @@
<script setup>
defineProps({
label: { type: String, required: true },
selected: { type: Boolean, default: false },
});
defineEmits(['select']);
</script>
<template>
<button :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
{{ label }}
</button>
</template>
<style scoped lang="scss">
@use './ConditionBadge.scss';
</style>
@@ -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; }
@@ -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' } };
@@ -0,0 +1,17 @@
<script setup>
defineProps({
amountMinor: { type: Number, required: true },
label: { type: String, default: 'Asking price' },
});
</script>
<template>
<span class="price-display">
<span class="price-display__label">{{ label }}</span>
<span class="price-display__value">${{ (amountMinor / 100).toFixed(2) }}</span>
</span>
</template>
<style scoped lang="scss">
@use './PriceDisplay.scss';
</style>
@@ -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); }
@@ -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' } };
@@ -0,0 +1,15 @@
<script setup>
defineProps({
status: { type: String, required: true },
});
</script>
<template>
<span :class="['status', `status--${status.toLowerCase()}`]">
{{ status }}
</span>
</template>
<style scoped lang="scss">
@use './StatusBadge.scss';
</style>
@@ -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); }
@@ -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' },
],
},
};
@@ -0,0 +1,26 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
images: { type: Array, required: true },
});
const selectedIndex = ref(0);
</script>
<template>
<div class="image-gallery">
<div class="image-gallery__stage">
<img v-if="props.images[selectedIndex]?.src" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" />
<span v-else class="image-gallery__placeholder" aria-label="No collectible image available">{{ props.images[selectedIndex]?.label ?? 'IMAGE' }}</span>
</div>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" @click="selectedIndex = index">
{{ index + 1 }}
</button>
</div>
</div>
</template>
<style scoped lang="scss">
@use './ImageGallery.scss';
</style>
@@ -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; }
@@ -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();
},
};
@@ -1,5 +1,6 @@
<script setup>
import { RouterLink } from 'vue-router';
import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
import StatusBadge from '@/components/atoms/StatusBadge.vue';
defineProps({
listing: {
@@ -10,14 +11,14 @@ defineProps({
</script>
<template>
<RouterLink class="listing-card" :to="{ name: 'listing-detail', params: { id: listing.id } }">
<a class="listing-card" :href="`/listings/${encodeURIComponent(listing.id)}`">
<span class="listing-card__mark" aria-hidden="true">PK</span>
<span>
<span class="listing-card__status">{{ listing.status }}</span>
<StatusBadge :status="listing.status" />
<span class="listing-card__name">{{ listing.productName }}</span>
<span class="listing-card__price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span>
<PriceDisplay :amount-minor="listing.askingPriceMinor" />
</span>
</RouterLink>
</a>
</template>
<style scoped lang="scss">
@@ -1,6 +1,11 @@
<script setup>
import { ref } from 'vue';
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
import StatusBadge from '@/components/atoms/StatusBadge.vue';
import ImageGallery from '@/components/molecules/ImageGallery.vue';
const selectedCondition = ref('Near Mint');
const listings = [
{ id: 'fixture-charizard', productName: 'Charizard ex #199/165', askingPriceMinor: 11500, status: 'AVAILABLE' },
@@ -59,17 +64,15 @@ const listings = [
<article class="showcase-panel">
<h3>Status treatments</h3>
<div class="status-row">
<span class="status status--available">Available</span>
<span class="status status--pending">Pending</span>
<span class="status status--sold">Sold</span>
<StatusBadge status="AVAILABLE" />
<StatusBadge status="PENDING" />
<StatusBadge status="SOLD" />
</div>
</article>
<article class="showcase-panel">
<h3>Condition selection</h3>
<div class="control-row">
<button v-for="condition in ['Near Mint', 'Lightly Played', 'Damaged']" :key="condition" class="condition" :class="{ 'condition--selected': selectedCondition === condition }" type="button" @click="selectedCondition = condition">
{{ condition }}
</button>
<ConditionBadge v-for="condition in ['Near Mint', 'Lightly Played', 'Damaged']" :key="condition" :label="condition" :selected="selectedCondition === condition" @select="selectedCondition = condition" />
</div>
<p class="fixture-label">Selected: {{ selectedCondition }}</p>
</article>
@@ -92,8 +95,8 @@ const listings = [
<h3>{{ listing.productName }}</h3>
<p class="fixture-card__meta">Condition: {{ listing.status === 'SOLD' ? 'Lightly Played' : 'Near Mint' }}</p>
</div>
<span :class="['status', `status--${listing.status.toLowerCase()}`]">{{ listing.status }}</span>
<p v-if="listing.status !== 'SOLD'" class="fixture-card__price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</p>
<StatusBadge :status="listing.status" />
<PriceDisplay v-if="listing.status !== 'SOLD'" :amount-minor="listing.askingPriceMinor" />
<p v-else class="fixture-card__meta">Sold history retained</p>
<div class="fixture-card__actions">
<button class="button button--primary" type="button">View collectible</button>
@@ -117,7 +120,7 @@ const listings = [
<h3>Listing detail</h3>
<p class="catalog-kicker">Available</p>
<h3>Asking price</h3>
<p class="fixture-card__price">$115.00</p>
<PriceDisplay :amount-minor="11500" />
<dl class="product-facts">
<div><dt>Condition</dt><dd>Near Mint</dd></div>
<div><dt>Market context</dt><dd>$120 reference</dd></div>
@@ -127,7 +130,7 @@ const listings = [
</article>
<article class="showcase-panel">
<h3>Image treatment</h3>
<div class="fixture-stage fixture-stage--image" aria-label="Museum-style contained collectible image stage"><span class="fixture-stage__glyph">GALLERY</span></div>
<ImageGallery :images="[{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }]" />
<p class="fixture-card__meta">Contained, neutral, aspect-ratio preserving, and free of cobalt image backgrounds.</p>
</article>
</div>