fix: preserve image manager state during action failures
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 17:57:28 -04:00
parent c5a07b2b5a
commit 72e9a79b67
9 changed files with 240 additions and 39 deletions
@@ -16,6 +16,7 @@
--color-success-500: #12a150; --color-success-500: #12a150;
--color-warning-500: #d88a00; --color-warning-500: #d88a00;
--color-danger-500: #e5484d; --color-danger-500: #e5484d;
--color-danger-solid: #b4232b;
--color-info-500: #1683c4; --color-info-500: #1683c4;
--color-success-text: #08783a; --color-success-text: #08783a;
--color-warning-text: #8a5700; --color-warning-text: #8a5700;
+1 -1
View File
@@ -79,7 +79,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 { 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--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--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
.button--danger { background: var(--color-danger-text); color: var(--color-white); } .button--danger { background: var(--color-danger-solid); color: var(--color-white); }
.button:disabled { cursor: not-allowed; opacity: 0.45; } .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 { 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); } .swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
@@ -44,3 +44,14 @@ export const RetryFailedImage = {
await expect(canvas.getByRole('button', { name: 'Retry processing' })).toBeInTheDocument(); await expect(canvas.getByRole('button', { name: 'Retry processing' })).toBeInTheDocument();
}, },
}; };
export const DisabledAfterRefreshFailure = {
args: { images, disabled: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
for (const button of canvas.getAllByRole('button', { name: /Edit alt text|Make primary|Move up|Move down|Delete image/ })) {
await expect(button).toBeDisabled();
}
},
};
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import ConditionBadge from '@/components/atoms/ConditionBadge.vue'; import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
import ImageGallery from '@/components/molecules/ImageGallery.vue'; import ImageGallery from '@/components/molecules/ImageGallery.vue';
@@ -7,6 +7,7 @@ import StatusBadge from '@/components/atoms/StatusBadge.vue';
const props = defineProps({ const props = defineProps({
images: { type: Array, required: true }, images: { type: Array, required: true },
disabled: { type: Boolean, default: false },
isSaving: { type: Boolean, default: false }, isSaving: { type: Boolean, default: false },
}); });
const emit = defineEmits(['upload', 'move', 'primary', 'alt-text', 'retry', 'delete']); const emit = defineEmits(['upload', 'move', 'primary', 'alt-text', 'retry', 'delete']);
@@ -17,16 +18,21 @@ const altTextDrafts = reactive({});
const isDragging = ref(false); const isDragging = ref(false);
function beginEditAltText(image) { function beginEditAltText(image) {
altTextDrafts[image.id] = image.altText ?? ''; altTextDrafts[image.id] ??= image.altText ?? '';
editingImageId.value = image.id; editingImageId.value = image.id;
} }
function saveAltText(imageId) { function saveAltText(imageId) {
emit('alt-text', { id: imageId, altText: altTextDrafts[imageId] ?? '' }); emit('alt-text', { id: imageId, altText: altTextDrafts[imageId] ?? '' });
editingImageId.value = null;
} }
watch(() => props.images, (images) => {
const edited = images.find((image) => image.id === editingImageId.value);
if (edited && edited.altText === altTextDrafts[edited.id]) editingImageId.value = null;
});
function queueUpload(fileList) { function queueUpload(fileList) {
if (props.isSaving || props.disabled) return;
const files = Array.from(fileList ?? []); const files = Array.from(fileList ?? []);
if (files.length > 0) emit('upload', files); if (files.length > 0) emit('upload', files);
} }
@@ -61,15 +67,15 @@ function handleDrop(event) {
> >
<p>Drag and drop images here, or choose files below.</p> <p>Drag and drop images here, or choose files below.</p>
<label class="fixture-label" for="admin-image-upload">Add images</label> <label class="fixture-label" for="admin-image-upload">Add images</label>
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" multiple :disabled="isSaving" @change="handleFileChange" /> <input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" multiple :disabled="isSaving || disabled" @change="handleFileChange" />
</div> </div>
<ImageGallery :images="orderedImages" /> <ImageGallery :images="orderedImages" />
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p> <p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
<p v-else-if="orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p> <p v-if="!isSaving && orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p>
<ul v-else class="admin-image-manager__list"> <ul v-if="orderedImages.length" class="admin-image-manager__list">
<li v-for="(image, index) in orderedImages" :key="image.id" class="admin-image-manager__item"> <li v-for="(image, index) in orderedImages" :key="image.id" class="admin-image-manager__item">
<div class="admin-image-manager__row"> <div class="admin-image-manager__row">
<ConditionBadge :label="image.altText || `Image ${index + 1}`" :selected="image.isPrimary" /> <ConditionBadge :label="image.altText || `Image ${index + 1}`" :selected="image.isPrimary" />
@@ -79,17 +85,17 @@ function handleDrop(event) {
<div class="admin-image-manager__row"> <div class="admin-image-manager__row">
<label class="fixture-label" :for="`admin-alt-text-${image.id}`">Alt text</label> <label class="fixture-label" :for="`admin-alt-text-${image.id}`">Alt text</label>
<input :id="`admin-alt-text-${image.id}`" v-model="altTextDrafts[image.id]" class="fixture-input" :disabled="editingImageId !== image.id" /> <input :id="`admin-alt-text-${image.id}`" v-model="altTextDrafts[image.id]" class="fixture-input" :disabled="isSaving || disabled || editingImageId !== image.id" />
<button v-if="editingImageId !== image.id" class="button button--secondary" type="button" @click="beginEditAltText(image)">Edit alt text</button> <button v-if="editingImageId !== image.id" class="button button--secondary" type="button" :disabled="isSaving || disabled" @click="beginEditAltText(image)">Edit alt text</button>
<button v-else class="button button--primary" type="button" @click="saveAltText(image.id)">Save alt text</button> <button v-else class="button button--primary" type="button" :disabled="isSaving || disabled" @click="saveAltText(image.id)">Save alt text</button>
</div> </div>
<div class="admin-image-manager__row"> <div class="admin-image-manager__row">
<button class="button button--secondary" type="button" :disabled="image.isPrimary" @click="emit('primary', image.id)">Make primary</button> <button class="button button--secondary" type="button" :disabled="isSaving || disabled || image.isPrimary" @click="emit('primary', image.id)">Make primary</button>
<button class="button button--secondary" type="button" :disabled="index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button> <button class="button button--secondary" type="button" :disabled="isSaving || disabled || index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button>
<button class="button button--secondary" type="button" :disabled="index === orderedImages.length - 1" @click="emit('move', { id: image.id, direction: 'down' })">Move down</button> <button class="button button--secondary" type="button" :disabled="isSaving || disabled || index === orderedImages.length - 1" @click="emit('move', { id: image.id, direction: 'down' })">Move down</button>
<button v-if="image.processingStatus === 'FAILED'" class="button button--secondary" type="button" @click="emit('retry', image.id)">Retry processing</button> <button v-if="image.processingStatus === 'FAILED'" class="button button--secondary" type="button" :disabled="isSaving || disabled" @click="emit('retry', image.id)">Retry processing</button>
<button class="button button--danger" type="button" @click="emit('delete', image.id)">Delete image</button> <button class="button button--danger" type="button" :disabled="isSaving || disabled" @click="emit('delete', image.id)">Delete image</button>
</div> </div>
</li> </li>
</ul> </ul>
+18 -5
View File
@@ -19,21 +19,32 @@ export function useAdminImages(inventoryItemId) {
const isLoading = ref(false); const isLoading = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const error = ref(null); const error = ref(null);
const loadError = ref(null);
const hasLoaded = ref(false);
async function load() { async function load() {
isLoading.value = true; isLoading.value = true;
error.value = null; loadError.value = null;
try { images.value = await listAdminImages(inventoryItemId); } try {
catch (requestError) { error.value = requestError; } images.value = await listAdminImages(inventoryItemId);
hasLoaded.value = true;
}
catch (requestError) { loadError.value = requestError; }
finally { isLoading.value = false; } finally { isLoading.value = false; }
} }
async function apply(action) { async function apply(action) {
if (!hasLoaded.value || isSaving.value || isLoading.value || loadError.value) return;
isSaving.value = true; isSaving.value = true;
error.value = null; error.value = null;
try { await action(csrfToken()); await load(); } try { await action(csrfToken()); }
catch (requestError) { error.value = requestError; } catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; } finally {
// Multi-file uploads and reordering can partially succeed. Reconcile even
// on failure, without replaying any mutation.
await load();
isSaving.value = false;
}
} }
async function uploadOne(file, isPrimary, token) { async function uploadOne(file, isPrimary, token) {
@@ -83,6 +94,8 @@ export function useAdminImages(inventoryItemId) {
isLoading, isLoading,
isSaving, isSaving,
error, error,
loadError,
hasLoaded,
load, load,
upload, upload,
move: moveImage, move: moveImage,
+136 -2
View File
@@ -1,5 +1,139 @@
import { expect, userEvent, within, waitFor } from '@storybook/test';
import AdminImagePage from './AdminImagePage.vue'; import AdminImagePage from './AdminImagePage.vue';
export default { title: 'Pages/AdminImagePage', component: AdminImagePage }; export default {
title: 'Pages/AdminImagePage', component: AdminImagePage,
decorators: [() => ({ template: '<div style="background: var(--color-background); color: var(--color-text)"><story /></div>' })],
beforeEach: (context) => {
const originalFetch = globalThis.fetch;
const scenario = context.parameters.imageScenario;
let reads = 0;
let writes = 0;
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } });
const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 });
globalThis.fetch = async (url, options = {}) => {
if (!String(url).includes('/admin/')) return originalFetch(url, options);
if (options.method === 'GET') {
reads += 1;
if (scenario === 'loading') return new Promise(() => {});
if (scenario === 'load-error' || (scenario === 'retry-load' && reads === 1) || (scenario === 'refresh-error' && reads === 2)) return fail('Image list unavailable.');
return reply(images);
}
writes += 1;
if (scenario === 'saving') return new Promise(() => {});
if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.');
if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }];
if ((scenario === 'edit-error' || scenario === 'delete-error') && writes === 1) return fail('Image action failed. Try again.');
if (options.method === 'DELETE') images = [];
if (String(url).endsWith('/alt-text')) images = images.map((image) => ({ ...image, altText: JSON.parse(options.body).altText }));
return reply(null);
};
return () => { globalThis.fetch = originalFetch; };
},
};
export const Overview = {}; export const Overview = {};
export const Loading = {
parameters: { imageScenario: 'loading' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('status')).toHaveTextContent('Loading images...');
await expect(canvas.queryByLabelText('Add images')).toBeNull();
},
};
export const LoadError = {
parameters: { imageScenario: 'load-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole('alert');
await expect(canvas.queryByLabelText('Add images')).toBeNull();
const reload = canvas.getByRole('button', { name: 'Reload images' });
reload.focus();
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(canvas.getByRole('button', { name: 'Reload images' })).toHaveFocus());
},
};
export const RetryLoad = {
parameters: { imageScenario: 'retry-load' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Reload images' }));
await expect(await canvas.findByLabelText('Add images')).toBeEnabled();
await expect(canvas.getByRole('region', { name: 'Image manager workspace' })).toHaveFocus();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const AltTextFailureAndRetry = {
parameters: { imageScenario: 'edit-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Edit alt text' }));
const field = canvas.getByLabelText('Alt text');
await userEvent.clear(field);
await userEvent.type(field, 'Updated card front');
await userEvent.click(canvas.getByRole('button', { name: 'Save alt text' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Image action failed. Try again.');
await waitFor(() => expect(field).toBeEnabled());
await expect(field).toHaveValue('Updated card front');
await expect(canvas.getByLabelText('Add images')).toBeEnabled();
await userEvent.click(canvas.getByRole('button', { name: 'Save alt text' }));
await waitFor(() => expect(canvas.getByRole('button', { name: 'Edit alt text' })).toBeEnabled());
await expect(canvas.queryByRole('alert')).toBeNull();
await expect(field).toHaveValue('Updated card front');
},
};
export const DeleteFailureAndRetry = {
parameters: { imageScenario: 'delete-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await canvas.findByRole('alert');
await waitFor(() => expect(canvas.getByRole('button', { name: 'Delete image' })).toBeEnabled());
await userEvent.click(canvas.getByRole('button', { name: 'Delete image' }));
await expect(await canvas.findByText('No images uploaded yet.')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const RefreshFailureAfterDelete = {
parameters: { imageScenario: 'refresh-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Could not refresh images.');
await expect(canvas.getByRole('button', { name: 'Delete image' })).toBeDisabled();
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
await userEvent.click(canvas.getByRole('button', { name: 'Reload images' }));
await expect(await canvas.findByText('No images uploaded yet.')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const SavingKeepsGallery = {
parameters: { imageScenario: 'saving' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await expect(canvas.getByRole('status')).toHaveTextContent('Saving image changes...');
await expect(canvas.getByRole('button', { name: 'Delete image' })).toBeDisabled();
await expect(canvas.getByRole('button', { name: 'Edit alt text' })).toBeDisabled();
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
},
};
export const MobileRetryLoad = { ...RetryLoad };
export const DarkAltTextRecovery = {
...AltTextFailureAndRetry,
globals: { theme: 'dark' },
};
export const PartialUploadFailure = {
parameters: { imageScenario: 'partial-upload' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const bytes = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), (char) => char.charCodeAt(0));
const input = await canvas.findByLabelText('Add images');
await userEvent.upload(input, [new File([bytes], 'front.png', { type: 'image/png' }), new File([bytes], 'back.png', { type: 'image/png' })]);
await expect(await canvas.findByRole('alert')).toHaveTextContent('Second upload failed.');
await waitFor(() => expect(input).toBeEnabled());
await expect(canvas.getAllByRole('button', { name: 'Delete image' })).toHaveLength(2);
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
},
};
+32 -15
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import AdminImageManager from '@/components/organisms/AdminImageManager.vue'; import AdminImageManager from '@/components/organisms/AdminImageManager.vue';
@@ -7,8 +7,15 @@ import { useAdminImages } from '@/composables/useAdminImages.js';
const route = useRoute(); const route = useRoute();
const inventoryItemId = route.params.id; const inventoryItemId = route.params.id;
const { images, isLoading, isSaving, error, load, upload, move, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId); const { images, isLoading, isSaving, error, loadError, hasLoaded, load, upload, move, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
const retryButton = ref(null);
const managerRegion = ref(null);
async function reloadImages() {
await load();
await nextTick();
(loadError.value ? retryButton.value : managerRegion.value)?.focus();
}
onMounted(load); onMounted(load);
</script> </script>
@@ -17,18 +24,28 @@ onMounted(load);
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink> <RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<p class="catalog-kicker">Admin inventory</p> <p class="catalog-kicker">Admin inventory</p>
<h1>Manage images</h1> <h1>Manage images</h1>
<p v-if="isLoading" class="catalog-state" role="status">Loading images...</p> <p v-if="isLoading" class="catalog-state" role="status">{{ hasLoaded ? 'Refreshing images...' : 'Loading images...' }}</p>
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p> <p v-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<AdminImageManager <div v-if="loadError" role="alert" class="catalog-state catalog-state--error">
v-else <p>{{ hasLoaded ? 'Could not refresh images. Reload before making more changes.' : 'Could not load images.' }} {{ loadError.message }}</p>
:images="images" <button ref="retryButton" class="button button--primary" type="button" :disabled="isLoading || isSaving" @click="reloadImages">Reload images</button>
:is-saving="isSaving" </div>
@upload="upload" <div v-if="hasLoaded" ref="managerRegion" tabindex="-1" aria-label="Image manager workspace" role="region" class="image-manager-region">
@move="({ id, direction }) => move(id, direction)" <AdminImageManager
@primary="setPrimary" :images="images"
@alt-text="({ id, altText }) => updateAltText(id, altText)" :is-saving="isSaving"
@retry="retry" :disabled="isLoading || Boolean(loadError)"
@delete="remove" @upload="upload"
/> @move="({ id, direction }) => move(id, direction)"
@primary="setPrimary"
@alt-text="({ id, altText }) => updateAltText(id, altText)"
@retry="retry"
@delete="remove"
/>
</div>
</main> </main>
</template> </template>
<style scoped>
.image-manager-region:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
</style>
+11
View File
@@ -108,3 +108,14 @@ Public galleries must support:
- screen-reader-friendly state - screen-reader-friendly state
Primary images power cards, search results, homepage previews, and social/share metadata. Primary images power cards, search results, homepage previews, and social/share metadata.
## Admin error recovery
Keep the loaded gallery and alt-text drafts mounted during image actions and
refreshes. Show action failures separately from list-read failures. After every
action, including a failed multi-file upload or reorder, reload image metadata to
reconcile any partial success without replaying writes. If that refresh fails,
retain the last loaded gallery but disable mutations until an explicit reload
succeeds. Failed initial loads show a reload action instead of an empty manager.
Reload restores keyboard focus to the manager or the retry control. Pending
operations block conflicting writes and uploads, including drag-and-drop.
+10 -2
View File
@@ -162,14 +162,22 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- Storybook covers loading, load errors, successful/repeated retry, save failure/retry, saving/disabled states, mobile retry and dark-theme save recovery. - Storybook covers loading, load errors, successful/repeated retry, save failure/retry, saving/disabled states, mobile retry and dark-theme save recovery.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 102 tests. Frontend-only change; no API or persistence changes. - Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 102 tests. Frontend-only change; no API or persistence changes.
### Latest Vertical Slice: Saved Views Error Recovery ### Saved Views Error Recovery
- Failed list reads no longer show an empty-list message or create form; Retry restores keyboard focus on success or repeated failure. - Failed list reads no longer show an empty-list message or create form; Retry restores keyboard focus on success or repeated failure.
- Create/delete actions clear stale feedback, announce progress, and block conflicting actions. Create failures preserve drafts; delete failures preserve the view and focus; successful deletion announces completion and focuses the list heading. - Create/delete actions clear stale feedback, announce progress, and block conflicting actions. Create failures preserve drafts; delete failures preserve the view and focus; successful deletion announces completion and focuses the list heading.
- Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery. - Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged. - Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged.
Next bounded slice: admin image-manager error recovery. A mutation error currently replaces the whole gallery with an error message. Keep loaded images and controls available after failed edits/uploads, distinguish initial-load failure, provide reload retry, and cover retry/failure states with Storybook interactions. ### Latest Vertical Slice: Image Manager Error Recovery
- Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds.
- Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops.
- Added page/component stories covering load/retry, edit/delete failure and retry, successful deletion followed by refresh failure, pending/disabled states, partial upload failure, mobile and dark-theme recovery.
- Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes.
Next bounded slice: atomic image reordering. Move up/down currently sends two separate order updates; failure between them can leave a partial reorder. Add a seller-scoped API operation that updates both positions transactionally, connect the controls to it, and verify rollback and ownership with integration tests.
## Next Milestone ## Next Milestone