This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
// headless iframe size that @storybook/test-runner navigates to directly.
|
// headless iframe size that @storybook/test-runner navigates to directly.
|
||||||
export default {
|
export default {
|
||||||
async preVisit(page, context) {
|
async preVisit(page, context) {
|
||||||
if (context.id?.startsWith('pages-catalogpage--mobile')) {
|
if (context.id?.includes('--enlarged')) {
|
||||||
await page.setViewportSize({ width: 375, height: 900 });
|
await page.setViewportSize({ width: 640, height: 900 });
|
||||||
|
} else if (context.id?.includes('--mobile')) {
|
||||||
|
await page.setViewportSize({ width: 320, height: 900 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ import ConditionBadge from './ConditionBadge.vue';
|
|||||||
|
|
||||||
export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
|
export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
|
||||||
export const Unselected = { args: { label: 'Near Mint', selected: false } };
|
export const Unselected = { args: { label: 'Near Mint', selected: false } };
|
||||||
export const Selected = { args: { label: 'Near Mint', selected: true } };
|
export const Selected = { args: { label: 'Near Mint', selected: true } };
|
||||||
|
export const ReadOnly = { args: { label: 'Near Mint', interactive: false } };
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
label: { type: String, required: true },
|
label: { type: String, required: true },
|
||||||
|
interactive: { type: Boolean, default: true },
|
||||||
selected: { type: Boolean, default: false },
|
selected: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
defineEmits(['select']);
|
defineEmits(['select']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
|
<button v-if="interactive" :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="condition">{{ label }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
.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 { 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__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__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 { display: flex; flex-wrap: wrap; 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 { 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-text); }
|
.image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); }
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { expect, within } from '@storybook/test';
|
||||||
import ImageGallery from "./ImageGallery.vue";
|
import ImageGallery from "./ImageGallery.vue";
|
||||||
|
|
||||||
export default { title: "Molecules/ImageGallery", component: ImageGallery };
|
export default { title: "Molecules/ImageGallery", component: ImageGallery };
|
||||||
@@ -47,3 +48,10 @@ export const LoadingImage = {
|
|||||||
},
|
},
|
||||||
parameters: { chromatic: { delay: 1000 } },
|
parameters: { chromatic: { delay: 1000 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BrokenApiImageFallback = {
|
||||||
|
args: { images: [{ id: 'broken-api', url: '/fixtures/missing-api-image.webp', altText: 'Back of collectible' }] },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(await within(canvasElement).findByRole('img', { name: 'Collectible image unavailable' })).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
|||||||
class="image-gallery__placeholder"
|
class="image-gallery__placeholder"
|
||||||
role="img"
|
role="img"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
props.images[selectedIndex]?.src
|
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
|
||||||
? 'Collectible image unavailable'
|
? 'Collectible image unavailable'
|
||||||
: 'No collectible image available'
|
: 'No collectible image available'
|
||||||
"
|
"
|
||||||
>{{
|
>{{
|
||||||
props.images[selectedIndex]?.src
|
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
|
||||||
? "IMAGE UNAVAILABLE"
|
? "IMAGE UNAVAILABLE"
|
||||||
: (props.images[selectedIndex]?.label ?? "IMAGE")
|
: (props.images[selectedIndex]?.label ?? "IMAGE")
|
||||||
}}</span
|
}}</span
|
||||||
@@ -83,6 +83,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
|||||||
<div
|
<div
|
||||||
v-if="props.images.length > 1"
|
v-if="props.images.length > 1"
|
||||||
class="image-gallery__controls"
|
class="image-gallery__controls"
|
||||||
|
role="group"
|
||||||
aria-label="Gallery images"
|
aria-label="Gallery images"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -90,7 +91,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
|||||||
:key="image.id"
|
:key="image.id"
|
||||||
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
|
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="`Show image ${index + 1}: ${image.alt}`"
|
:aria-label="`Show image ${index + 1}: ${image.altText || image.alt || image.label || 'Collectible image'}`"
|
||||||
:aria-current="index === selectedIndex ? 'true' : undefined"
|
:aria-current="index === selectedIndex ? 'true' : undefined"
|
||||||
@click="
|
@click="
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, within } from '@storybook/test';
|
import { expect, userEvent, within } from '@storybook/test';
|
||||||
import CollectibleDetail from "./CollectibleDetail.vue";
|
import CollectibleDetail from "./CollectibleDetail.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -68,3 +68,43 @@ export const SellerDescriptionDark = {
|
|||||||
|
|
||||||
export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } };
|
export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } };
|
||||||
export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } };
|
export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } };
|
||||||
|
|
||||||
|
export const MobileSellerContact = {
|
||||||
|
args: {
|
||||||
|
...ListingDetail.args,
|
||||||
|
title: 'Charizard-ex-Special-Illustration-Rare-Collector-Edition',
|
||||||
|
sellerContactEmail: 'collectibles-and-trading-cards@independent-collector.example',
|
||||||
|
images: Array.from({ length: 10 }, (_, index) => ({ id: `copy-${index}`, altText: `Collectible view ${index + 1}`, label: `VIEW ${index + 1}` })),
|
||||||
|
},
|
||||||
|
decorators: [() => ({ template: '<main style="max-width: 100%; padding: 16px; background: var(--color-background); color: var(--color-text)"><story /></main>' })],
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const first = canvas.getByRole('button', { name: 'Show image 1: Collectible view 1' });
|
||||||
|
const second = canvas.getByRole('button', { name: 'Show image 2: Collectible view 2' });
|
||||||
|
first.focus();
|
||||||
|
await userEvent.tab();
|
||||||
|
await expect(second).toHaveFocus();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await expect(second).toHaveAttribute('aria-current', 'true');
|
||||||
|
await expect(first).not.toHaveAttribute('aria-current');
|
||||||
|
await expect(canvas.queryByRole('button', { name: 'Near Mint' })).toBeNull();
|
||||||
|
for (let index = 0; index < 9; index += 1) await userEvent.tab();
|
||||||
|
const email = canvas.getByRole('link', { name: /collectibles-and-trading-cards@/ });
|
||||||
|
await expect(email).toHaveFocus();
|
||||||
|
await expect(email).toHaveAttribute('href', `mailto:${MobileSellerContact.args.sellerContactEmail}`);
|
||||||
|
await userEvent.tab();
|
||||||
|
await expect(canvas.getByRole('link', { name: '555-0100' })).toHaveFocus();
|
||||||
|
await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// CSS text resizing complements the 640px viewport (1280px at 200% zoom).
|
||||||
|
export const EnlargedSellerContact = {
|
||||||
|
...MobileSellerContact,
|
||||||
|
beforeEach: () => {
|
||||||
|
const original = document.documentElement.style.fontSize;
|
||||||
|
document.documentElement.style.fontSize = '200%';
|
||||||
|
return () => { document.documentElement.style.fontSize = original; };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ defineProps({
|
|||||||
label="Market value"
|
label="Market value"
|
||||||
:amount-minor="marketValueMinor"
|
:amount-minor="marketValueMinor"
|
||||||
/>
|
/>
|
||||||
<ConditionBadge v-if="condition" :label="condition" />
|
<ConditionBadge v-if="condition" :label="condition" :interactive="false" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="marketValueUpdatedAt" class="collectible-detail__market-updated">
|
<p v-if="marketValueUpdatedAt" class="collectible-detail__market-updated">
|
||||||
Market value updated
|
Market value updated
|
||||||
@@ -70,6 +70,11 @@ defineProps({
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: var(--space-12);
|
gap: var(--space-12);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.collectible-detail__gallery,
|
||||||
|
.collectible-detail__identity {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.collectible-detail__identity {
|
.collectible-detail__identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -26,14 +26,38 @@ export const MobileFilterDisclosure = {
|
|||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
await expect(setFilter).not.toBeVisible();
|
await expect(setFilter).not.toBeVisible();
|
||||||
|
|
||||||
await userEvent.click(toggle);
|
toggle.focus();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(setFilter).toBeVisible();
|
await expect(setFilter).toBeVisible();
|
||||||
|
|
||||||
await userEvent.click(toggle);
|
toggle.focus();
|
||||||
|
await userEvent.keyboard(' ');
|
||||||
|
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
await expect(setFilter).not.toBeVisible();
|
await expect(setFilter).not.toBeVisible();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const MobilePopulatedCatalog = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (url) => new Response(JSON.stringify({ data: String(url).includes('/search?') ? {
|
||||||
|
items: [{ id: 'collectible-1', productName: 'Charizard ex special illustration rare', askingPriceMinor: 2500, status: 'AVAILABLE' }],
|
||||||
|
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||||
|
} : [] }), { headers: { 'content-type': 'application/json' } });
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const listing = await canvas.findByRole('link', { name: /Charizard ex/ });
|
||||||
|
const toggle = canvas.getByRole('button', { name: /Filters/ });
|
||||||
|
toggle.focus();
|
||||||
|
await userEvent.tab();
|
||||||
|
await expect(listing).toHaveFocus();
|
||||||
|
await expect(listing).toHaveAttribute('href', '/listings/collectible-1');
|
||||||
|
await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,3 +19,25 @@ export const PublicSellerDescription = {
|
|||||||
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Loading = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = () => new Promise(() => {});
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(within(canvasElement).getByRole('status')).toHaveTextContent('Loading');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Unavailable = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ error: { message: 'This collectible is unavailable.' } }), { status: 404 });
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(await within(canvasElement).findByRole('alert')).toHaveTextContent('Listing not found.');
|
||||||
|
await expect(within(canvasElement).getByRole('link', { name: 'Back to catalog' })).toHaveAttribute('href', '/');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ onMounted(async () => {
|
|||||||
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
|
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
|
||||||
>Back to catalog</RouterLink
|
>Back to catalog</RouterLink
|
||||||
>
|
>
|
||||||
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
|
<p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p>
|
||||||
<p v-else-if="error" class="catalog-state catalog-state--error">
|
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</p>
|
</p>
|
||||||
<CollectibleDetail
|
<CollectibleDetail
|
||||||
|
|||||||
@@ -19,3 +19,25 @@ export const PublicSellerDescription = {
|
|||||||
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Loading = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = () => new Promise(() => {});
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(within(canvasElement).getByRole('status')).toHaveTextContent('Loading');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Unavailable = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ error: { message: 'This collectible is unavailable.' } }), { status: 404 });
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(await within(canvasElement).findByRole('alert')).toHaveTextContent('Product not found.');
|
||||||
|
await expect(within(canvasElement).getByRole('link', { name: 'Back to catalog' })).toHaveAttribute('href', '/');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<main class="detail-page">
|
<main class="detail-page">
|
||||||
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
|
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
|
||||||
<p v-if="isLoading" class="catalog-state">Loading product...</p>
|
<p v-if="isLoading" class="catalog-state" role="status">Loading product...</p>
|
||||||
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
|
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
|
||||||
<CollectibleDetail
|
<CollectibleDetail
|
||||||
v-else-if="product"
|
v-else-if="product"
|
||||||
:title="product.name"
|
:title="product.name"
|
||||||
|
|||||||
@@ -29,3 +29,44 @@ Run automated accessibility testing in CI.
|
|||||||
Serious accessibility regressions should fail CI.
|
Serious accessibility regressions should fail CI.
|
||||||
|
|
||||||
Automated tests do not replace manual keyboard and assistive-technology review for important workflows.
|
Automated tests do not replace manual keyboard and assistive-technology review for important workflows.
|
||||||
|
|
||||||
|
## Public browsing review — 2026-09-10
|
||||||
|
|
||||||
|
Scope: catalog filters and listing links, listing/product detail states, image
|
||||||
|
selection, condition display, and seller contact links. Fixtures use public DTO
|
||||||
|
shapes, including `url`/`altText`, ten images, and a long seller email.
|
||||||
|
|
||||||
|
Findings fixed:
|
||||||
|
|
||||||
|
- Gallery buttons now use API `altText`, with legacy `alt` and label fallbacks,
|
||||||
|
rather than announcing “undefined”. Controls form a named group and wrap.
|
||||||
|
- Failed API `url` images now announce an unavailable image rather than claiming
|
||||||
|
no image exists.
|
||||||
|
- Detail text and grid children reflow without forcing horizontal scrolling;
|
||||||
|
long seller contact addresses remain readable and focusable.
|
||||||
|
- Read-only detail conditions are text, removing a button that had no action.
|
||||||
|
- Listing and product loading/error messages expose status/alert roles.
|
||||||
|
|
||||||
|
Browser review: Codex in-app browser, keyboard and accessibility-tree inspection,
|
||||||
|
with screenshots checked at a 320px viewport. Tab/Enter select gallery images,
|
||||||
|
selection remains indicated, Tab reaches email then phone without a condition
|
||||||
|
button detour, and focus outlines remain visible. The populated catalog's filter
|
||||||
|
disclosure opens via Enter; expanded filters and the listing card fit at 320px.
|
||||||
|
Contact targets were inspected without launching email or telephone applications.
|
||||||
|
|
||||||
|
Enlargement review: 640px viewport (the CSS layout width of a 1280px browser at
|
||||||
|
200% zoom), additionally with root text enlarged to 200%. Detail text, gallery
|
||||||
|
controls, and seller links remain readable and keyboard-accessible without
|
||||||
|
horizontal scrolling. This is reflow/text-resize coverage, not a native browser
|
||||||
|
zoom result: browser-zoom shortcuts were unavailable in the review browser.
|
||||||
|
|
||||||
|
Regression coverage: mobile catalog filter keyboard interaction and listing
|
||||||
|
focus/link target; gallery selection and contact focus order; narrow and enlarged
|
||||||
|
reflow assertions; read-only condition story; broken API image fallback; and
|
||||||
|
loading/not-found announcements on both public detail pages. Existing light/dark,
|
||||||
|
empty-image, sold, and pending stories remain part of the accessibility gate.
|
||||||
|
|
||||||
|
Remaining manual coverage: native browser zoom, screen-reader speech and route
|
||||||
|
announcements (VoiceOver/Safari or NVDA/Firefox), and the full live-data navigation
|
||||||
|
journey. Automated checks and accessibility-tree inspection do not certify WCAG
|
||||||
|
conformance or replace those reviews.
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ docker context use orbstack
|
|||||||
|
|
||||||
Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
||||||
|
|
||||||
### Latest Vertical Slice: Public Seller Description
|
### Public Seller Description
|
||||||
|
|
||||||
- Sellers can save, edit, and clear an optional public description in Settings.
|
- Sellers can save, edit, and clear an optional public description in Settings.
|
||||||
- Descriptions are trimmed plain text, limited to 2,000 characters server-side, and stored in existing seller settings JSON without a migration.
|
- Descriptions are trimmed plain text, limited to 2,000 characters server-side, and stored in existing seller settings JSON without a migration.
|
||||||
@@ -131,7 +131,14 @@ Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 ap
|
|||||||
- Coverage includes validation/partial-update/clearing tests, public HTTP contracts, PostgreSQL repository reads, settings edit/clear interactions, both public page integrations, and absent/plain-text/dark-theme component stories.
|
- Coverage includes validation/partial-update/clearing tests, public HTTP contracts, PostgreSQL repository reads, settings edit/clear interactions, both public page integrations, and absent/plain-text/dark-theme component stories.
|
||||||
- Validation: 166 API tests, 23 isolated PostgreSQL/Redis integration tests, 10 web tests, production web build, Storybook build, and Storybook interaction/accessibility runner (32 suites / 84 tests).
|
- Validation: 166 API tests, 23 isolated PostgreSQL/Redis integration tests, 10 web tests, production web build, Storybook build, and Storybook interaction/accessibility runner (32 suites / 84 tests).
|
||||||
|
|
||||||
Next bounded slice: review the public catalog → listing/product detail → seller contact journey with keyboard navigation, mobile layout, and 200% zoom; fix discovered accessibility issues and add regression stories/tests. Record manual results in `docs/ACCESSIBILITY.md`. This advances the documented accessibility milestone without requiring external provider credentials.
|
### Latest Vertical Slice: Public Browsing Accessibility
|
||||||
|
|
||||||
|
- Public gallery labels use API image descriptions, controls wrap at narrow widths, and failed API image URLs have an accurate accessible fallback.
|
||||||
|
- Long detail text and seller contacts reflow, read-only condition badges no longer add inactive buttons to keyboard navigation, and detail loading/errors expose announcement roles.
|
||||||
|
- Added keyboard, 320px reflow, 200% text enlargement, and detail-state regression stories. Browser review and its native-zoom/screen-reader limitations are recorded in `docs/ACCESSIBILITY.md`.
|
||||||
|
- Validation: 166 API tests, 10 web tests, production web build, Storybook build, and 32 Storybook suites / 93 tests. This frontend-only slice changes no persistence or API contracts.
|
||||||
|
|
||||||
|
Next bounded slice: public route navigation accessibility. Review catalog → listing/product → back navigation with a screen reader, add appropriate page titles and route-focus handling where missing, and cover navigation in browser tests. Include native 200% zoom review.
|
||||||
|
|
||||||
## Next Milestone
|
## Next Milestone
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user