feat(F-059): completed feature
This commit is contained in:
@@ -3057,6 +3057,40 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-19T13:17:51Z"
|
"completed_at": "2026-08-19T13:17:51Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-059",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Image display respects aspect ratio no crop on non-square thumbs",
|
||||||
|
"problem": "Frontend uses object-cover and aspect-square which crops non-square images; backend resize has no max-height cap so extreme aspects break layouts",
|
||||||
|
"goal": "Images preserve aspect ratio at every viewport. Backend resize uses fit inside with max-height cap. Frontend uses object-contain and aspect-auto for product images. Cached square thumbs are fine; non-square thumbs display without distortion or cropping.",
|
||||||
|
"scope_in": [
|
||||||
|
"Backend resize sharp fit inside with max-h cap; frontend object-cover replaced with object-contain on product images; aspect-square replaced with aspect-auto on product image containers; sensible max bounds on containers; bg fill for empty space"
|
||||||
|
],
|
||||||
|
"scope_out": [
|
||||||
|
"No new endpoints",
|
||||||
|
"no schema change",
|
||||||
|
"no thumbnail regeneration of legacy files"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Problem: Frontend uses object-cover and aspect-square which crops non-square images; backend resize has no max-height cap so extreme aspects break layouts. Goal: Images preserve aspect ratio at every viewport. Backend resize uses fit inside with max-height cap. Frontend uses object-contain and aspect-auto for product images. Cached square thumbs are fine; non-square thumbs display without distortion or cropping.. Scope IN: Backend resize sharp fit inside with max-h cap; frontend object-cover replaced with object-contain on product images; aspect-square replaced with aspect-auto on product image containers; sensible max bounds on containers; bg fill for empty space. Scope OUT: No new endpoints, no schema change, no thumbnail regeneration of legacy files. Type: fix. Priority: high. Risk: low.",
|
||||||
|
"acceptance": [
|
||||||
|
"Backend resize uses fit inside so cached thumbnail is bounded by max-width and max-height without crop",
|
||||||
|
"40px and 200px thumbnails on disk are never square-cropped from a non-square source",
|
||||||
|
"Frontend product images render with object-contain and aspect-auto, no clipping at any viewport",
|
||||||
|
"Admin product list table shows the full image (not cropped to square)",
|
||||||
|
"verify.sh is green"
|
||||||
|
],
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-19",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-19T13:32:56Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,11 +158,13 @@ export default function ProductsPage() {
|
|||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{p.images?.[0]?.url ? (
|
{p.images?.[0]?.url ? (
|
||||||
<img
|
<div className="w-10 h-14 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
src={p.images[0].url.replace('/uploads/', '/uploads/40/')}
|
<img
|
||||||
alt={p.name}
|
src={p.images[0].url.replace('/uploads/', '/uploads/40/')}
|
||||||
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
|
alt={p.name}
|
||||||
/>
|
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
|
||||||
🌿
|
🌿
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ const PEER_UPLOAD_DIRS = [
|
|||||||
|
|
||||||
/** Thumbnail widths pre-generated for lists (40px) and previews (200px). */
|
/** Thumbnail widths pre-generated for lists (40px) and previews (200px). */
|
||||||
const THUMBNAIL_WIDTHS = [40, 200];
|
const THUMBNAIL_WIDTHS = [40, 200];
|
||||||
|
/**
|
||||||
|
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||||
|
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||||
|
* cropping. A non-square source produces a non-square thumbnail that preserves
|
||||||
|
* the original aspect ratio, so the frontend can render it with
|
||||||
|
* `object-contain` inside a matching box.
|
||||||
|
*/
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4;
|
||||||
|
|
||||||
async function mirrorToPeers(filePath: string): Promise<void> {
|
async function mirrorToPeers(filePath: string): Promise<void> {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
@@ -47,7 +55,10 @@ async function generateThumbnails(buffer: Buffer, filename: string): Promise<voi
|
|||||||
THUMBNAIL_WIDTHS.map(async (width) => {
|
THUMBNAIL_WIDTHS.map(async (width) => {
|
||||||
const thumbDir = path.join(dir, String(width));
|
const thumbDir = path.join(dir, String(width));
|
||||||
await mkdir(thumbDir, { recursive: true });
|
await mkdir(thumbDir, { recursive: true });
|
||||||
const resized = await sharp(buffer).resize({ width, withoutEnlargement: true }).toBuffer();
|
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||||
|
const resized = await sharp(buffer)
|
||||||
|
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer();
|
||||||
await writeFile(path.join(thumbDir, filename), resized);
|
await writeFile(path.join(thumbDir, filename), resized);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||||
|
/**
|
||||||
|
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||||
|
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||||
|
* cropping, so a non-square source produces a non-square thumbnail that
|
||||||
|
* preserves the original aspect ratio. The frontend renders the result with
|
||||||
|
* `object-contain`.
|
||||||
|
*/
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4;
|
||||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
@@ -54,7 +62,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
|||||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
const { default: sharp } = await import('sharp');
|
const { default: sharp } = await import('sharp');
|
||||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||||
|
return await sharp(source)
|
||||||
|
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,12 +173,17 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{images.map((img, idx) => (
|
{images.map((img, idx) => (
|
||||||
<div key={img.id} className="relative group">
|
<div key={img.id} className="relative group">
|
||||||
{/* Use 200px thumbnail for editor preview to reduce bandwidth */}
|
{/* Use 200px thumbnail for editor preview to reduce bandwidth.
|
||||||
<img
|
Container caps the bounding box (max 280px tall to match the
|
||||||
src={img.url.replace('/uploads/', '/uploads/200/')}
|
1.4 aspect ratio used by the backend thumbnail); the image
|
||||||
alt={img.altText ?? img.url}
|
preserves its source aspect ratio via `object-contain`. */}
|
||||||
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
|
<div className="w-full max-h-72 aspect-[5/7] bg-gray-100 rounded-xl overflow-hidden flex items-center justify-center">
|
||||||
/>
|
<img
|
||||||
|
src={img.url.replace('/uploads/', '/uploads/200/')}
|
||||||
|
alt={img.altText ?? img.url}
|
||||||
|
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* Main badge */}
|
{/* Main badge */}
|
||||||
{idx === 0 && (
|
{idx === 0 && (
|
||||||
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
|
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -79,9 +79,9 @@ export default async function BrandPage({ params }: Props) {
|
|||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">🌿</span>
|
<span className="text-5xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ export default async function CategoryPage({ params }: Props) {
|
|||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">🌿</span>
|
<span className="text-5xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -100,13 +100,16 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div>
|
<div>
|
||||||
<div className="relative aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
{/* Detail image: bound the box at max-h 500px and let the image keep
|
||||||
|
its natural aspect ratio with `object-contain`. Non-square photos
|
||||||
|
render fully inside the box, never cropped. */}
|
||||||
|
<div className="relative aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image
|
<Image
|
||||||
src={product.images[0].url}
|
src={product.images[0].url}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-contain"
|
||||||
priority
|
priority
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -48,9 +48,19 @@ export default async function ProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
{/* Image container: bounded box (max 5:7 aspect ratio to match
|
||||||
|
backend thumbnail sizing). The image preserves its source
|
||||||
|
aspect ratio via `object-contain`, so non-square images
|
||||||
|
display without cropping. */}
|
||||||
|
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
<Image
|
||||||
|
src={product.images[0].url}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">🌿</span>
|
<span className="text-5xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ export default async function SearchPage({ searchParams }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">🌿</span>
|
<span className="text-5xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||||
|
/**
|
||||||
|
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||||
|
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||||
|
* cropping, so a non-square source produces a non-square thumbnail that
|
||||||
|
* preserves the original aspect ratio. The frontend renders the result with
|
||||||
|
* `object-contain`.
|
||||||
|
*/
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4;
|
||||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
@@ -55,7 +63,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
|||||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
const { default: sharp } = await import('sharp');
|
const { default: sharp } = await import('sharp');
|
||||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||||
|
return await sharp(source)
|
||||||
|
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,18 @@ function CartItemRow({ item }: { item: CartItem }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
||||||
{/* Image */}
|
{/* Image: bounded box (80x112 max, 5:7 ratio matching the backend
|
||||||
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
thumbnail). The image preserves its source aspect ratio via
|
||||||
|
`object-contain` so non-square images are not cropped. */}
|
||||||
|
<div className="w-20 max-h-28 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||||
{item.imageUrl ? (
|
{item.imageUrl ? (
|
||||||
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
|
<Image
|
||||||
|
src={item.imageUrl.replace('/uploads/', '/uploads/40/')}
|
||||||
|
alt={item.productName}
|
||||||
|
width={80}
|
||||||
|
height={112}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-3xl">🌿</span>
|
<span className="text-3xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export default function CheckoutClient() {
|
|||||||
<div key={item.variantId} className="flex gap-3">
|
<div key={item.variantId} className="flex gap-3">
|
||||||
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||||
{item.imageUrl
|
{item.imageUrl
|
||||||
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
|
? <Image src={item.imageUrl.replace('/uploads/', '/uploads/40/')} alt={item.productName} width={40} height={56} className="object-contain" />
|
||||||
: <span className="text-2xl">🌿</span>
|
: <span className="text-2xl">🌿</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export default async function FeaturedProducts() {
|
|||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
|
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
|
||||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">🌿</span>
|
<span className="text-5xl">🌿</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ function LiveSearchResults({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
<div className="w-10 h-14 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image
|
<Image
|
||||||
src={product.images[0].url}
|
src={product.images[0].url.replace('/uploads/', '/uploads/40/')}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={56}
|
||||||
className="object-cover w-full h-full"
|
className="object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg">🌿</span>
|
<span className="text-lg">🌿</span>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -69,7 +69,7 @@ export default async function ProductPage({ params }: PageProps) {
|
|||||||
<img
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.altText}
|
alt={image.altText}
|
||||||
className="aspect-[4/3] w-full max-h-[600px] object-cover"
|
className="block w-full max-h-[600px] object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex aspect-[4/3] items-center justify-center text-emerald-900">
|
<div className="flex aspect-[4/3] items-center justify-center text-emerald-900">
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||||
|
/**
|
||||||
|
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||||
|
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||||
|
* cropping, so a non-square source produces a non-square thumbnail that
|
||||||
|
* preserves the original aspect ratio. The frontend renders the result with
|
||||||
|
* `object-contain`.
|
||||||
|
*/
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4;
|
||||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
@@ -55,7 +63,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
|||||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
const { default: sharp } = await import('sharp');
|
const { default: sharp } = await import('sharp');
|
||||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||||
|
return await sharp(source)
|
||||||
|
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
|
|||||||
href={product.url}
|
href={product.url}
|
||||||
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
||||||
>
|
>
|
||||||
<div className="flex aspect-[4/3] items-center justify-center bg-emerald-50 text-sm text-emerald-900">
|
<div className="flex aspect-[5/7] max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
|
||||||
{mainImage ? (
|
{mainImage ? (
|
||||||
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={mainImage.url} alt={mainImage.altText} className="h-full w-full object-cover" />
|
<img
|
||||||
|
src={mainImage.url}
|
||||||
|
alt={mainImage.altText}
|
||||||
|
className="max-h-full max-w-full w-auto h-auto object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>Producto ecológico</span>
|
<span>Producto ecológico</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
132
work/artifacts/F-059/implementer.md
Normal file
132
work/artifacts/F-059/implementer.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# F-059 — Implementer evidence
|
||||||
|
|
||||||
|
## Scope delivered
|
||||||
|
|
||||||
|
Product images were being cropped whenever they were not square. The root
|
||||||
|
cause spanned the backend resize (no max-height cap, so extreme aspects
|
||||||
|
broke layouts) and the frontend CSS (every product image container forced
|
||||||
|
`aspect-square` and used `object-cover`, which silently clipped non-square
|
||||||
|
photos). The cached thumbnails themselves were already aspect-preserving
|
||||||
|
(width-bound), but the frontend never trusted them — it tried to force
|
||||||
|
the image into a square box.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Backend — bounded, aspect-preserving resize
|
||||||
|
|
||||||
|
In all four upload/thumbnail locations:
|
||||||
|
|
||||||
|
- `project/apps/admin/src/app/api/upload/route.ts`
|
||||||
|
- `project/apps/admin/src/app/uploads/[...path]/route.ts`
|
||||||
|
- `project/frontend/src/app/uploads/[...path]/route.ts`
|
||||||
|
- `project/storefront/src/app/uploads/[...path]/route.ts`
|
||||||
|
|
||||||
|
The `sharp().resize(...)` call now uses `fit: 'inside'` together with a
|
||||||
|
max-height bound:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4; // max 5:7 aspect
|
||||||
|
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||||
|
sharp(buffer)
|
||||||
|
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toBuffer();
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces thumbnails that:
|
||||||
|
|
||||||
|
- Preserve the source aspect ratio (no distortion, no cropping)
|
||||||
|
- Fit inside a bounded box (max `width × width * 1.4`)
|
||||||
|
- Stay under the size cap that the frontend container can display
|
||||||
|
|
||||||
|
### Frontend — `object-contain` and bounded containers
|
||||||
|
|
||||||
|
Every product image container was rewritten. The `aspect-square` /
|
||||||
|
`object-cover` pattern is gone; instead each container defines a
|
||||||
|
bounded box (`aspect-[5/7] max-h-…`) and the image uses
|
||||||
|
`object-contain` so the natural aspect ratio is preserved.
|
||||||
|
|
||||||
|
| File | Container before | Container after |
|
||||||
|
| ---- | ---------------- | --------------- |
|
||||||
|
| `project/frontend/src/app/products/page.tsx` | `aspect-square relative` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` |
|
||||||
|
| `project/frontend/src/app/products/[slug]/page.tsx` | `aspect-square max-h-[500px]` + `object-cover` | `aspect-[5/7] max-h-[500px]` + `object-contain` |
|
||||||
|
| `project/frontend/src/app/brands/[slug]/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` |
|
||||||
|
| `project/frontend/src/app/search/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` |
|
||||||
|
| `project/frontend/src/app/categories/[slug]/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` |
|
||||||
|
| `project/frontend/src/components/home/FeaturedProducts.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` |
|
||||||
|
| `project/frontend/src/components/checkout/CheckoutClient.tsx` | 48×48 `object-cover` | 40×56 `object-contain`, served from `/uploads/40/` |
|
||||||
|
| `project/frontend/src/components/cart/CartPageContent.tsx` | 80×80 `object-cover` | `w-20 max-h-28`, 80×112 `object-contain`, served from `/uploads/40/` |
|
||||||
|
| `project/frontend/src/components/layout/Header.tsx` | 40×40 `object-cover` | 40×56 `object-contain`, served from `/uploads/40/` |
|
||||||
|
| `project/apps/admin/src/app/(dashboard)/products/page.tsx` | 40×40 `object-cover` | `w-10 h-14` wrapper, 40×56 `object-contain`, served from `/uploads/40/` |
|
||||||
|
| `project/apps/admin/src/features/products/components/sections/ImagesSection.tsx` | `w-full aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain`, served from `/uploads/200/` |
|
||||||
|
| `project/storefront/src/app/productos/[slug]/page.tsx` | `aspect-[4/3] max-h-[600px] object-cover` | `max-h-[600px] object-contain` |
|
||||||
|
| `project/storefront/src/components/product-card.tsx` | `aspect-[4/3]` parent, child `object-cover` | `aspect-[5/7] max-h-72` parent, child `object-contain` |
|
||||||
|
|
||||||
|
All listing cards now use the same `aspect-[5/7] max-h-72` container, so
|
||||||
|
visual rhythm stays consistent regardless of the source aspect.
|
||||||
|
|
||||||
|
## Acceptance traceability
|
||||||
|
|
||||||
|
| Acceptance criterion | How it is met |
|
||||||
|
| -------------------- | ------------- |
|
||||||
|
| Backend resize uses `fit: 'inside'` so cached thumbnail is bounded by max-width and max-height without crop | All four resize sites use `fit: 'inside'` with `MAX_HEIGHT_RATIO = 1.4`. |
|
||||||
|
| 40px and 200px thumbnails on disk are never square-cropped from a non-square source | Verified by uploading 600×800, 400×1600, and 800×400 sources. Cached thumbnails: 40×53, 14×56, 40×20 (40px thumb) and 200×267, 70×280, 200×100 (200px thumb). Aspect preserved, never cropped. |
|
||||||
|
| Frontend product images render with `object-contain` and `aspect-auto`, no clipping at any viewport | Every product image container uses `object-contain` (or `object-contain` on the inner `<img>`). Container aspect ratio is `5/7` max (or removed entirely on storefront detail), and `overflow-hidden` keeps the box. |
|
||||||
|
| Admin product list table shows the full image (not cropped to square) | `w-10 h-14` container with `object-contain` inside. The 40px thumbnail's natural 40×53 is fully visible. |
|
||||||
|
| `verify.sh` is green | Exit 0. |
|
||||||
|
|
||||||
|
## Manual verification (with the running dev stack)
|
||||||
|
|
||||||
|
```
|
||||||
|
Source 600×800 (portrait)
|
||||||
|
40px thumb → 40×53 (aspect 3:4 preserved, fits inside 40×56)
|
||||||
|
200px thumb → 200×267 (aspect 3:4 preserved, fits inside 200×280)
|
||||||
|
|
||||||
|
Source 400×1600 (extreme portrait)
|
||||||
|
40px thumb → 14×56 (height capped at 56, width computed from aspect)
|
||||||
|
200px thumb → 70×280 (height capped at 280)
|
||||||
|
|
||||||
|
Source 800×400 (landscape)
|
||||||
|
40px thumb → 40×20
|
||||||
|
200px thumb → 200×100
|
||||||
|
|
||||||
|
Source 800×800 (square, no change)
|
||||||
|
40px thumb → 40×40
|
||||||
|
200px thumb → 200×200
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl http://192.168.18.93:3003/products | grep -oE "object-cover|aspect-square|object-contain|aspect-\[5/7\]"
|
||||||
|
aspect-[5/7]
|
||||||
|
object-contain
|
||||||
|
```
|
||||||
|
|
||||||
|
No `object-cover` or `aspect-square` strings left in the rendered HTML.
|
||||||
|
|
||||||
|
## Build verification
|
||||||
|
|
||||||
|
- `npm run typecheck` (project/) — exit 0
|
||||||
|
- `npx tsc --noEmit` (apps/admin, frontend, storefront) — exit 0
|
||||||
|
- `./scripts/verify.sh` — exit 0
|
||||||
|
- All services up (backend 3000, frontend 3003, admin 3004, storefront 3005)
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
```
|
||||||
|
project/apps/admin/src/app/api/upload/route.ts (modified)
|
||||||
|
project/apps/admin/src/app/uploads/[...path]/route.ts (modified)
|
||||||
|
project/apps/admin/src/app/(dashboard)/products/page.tsx (modified)
|
||||||
|
project/apps/admin/src/features/products/components/sections/ImagesSection.tsx (modified)
|
||||||
|
project/frontend/src/app/uploads/[...path]/route.ts (modified)
|
||||||
|
project/frontend/src/app/products/page.tsx (modified)
|
||||||
|
project/frontend/src/app/products/[slug]/page.tsx (modified)
|
||||||
|
project/frontend/src/app/brands/[slug]/page.tsx (modified)
|
||||||
|
project/frontend/src/app/search/page.tsx (modified)
|
||||||
|
project/frontend/src/app/categories/[slug]/page.tsx (modified)
|
||||||
|
project/frontend/src/components/home/FeaturedProducts.tsx (modified)
|
||||||
|
project/frontend/src/components/layout/Header.tsx (modified)
|
||||||
|
project/frontend/src/components/checkout/CheckoutClient.tsx (modified)
|
||||||
|
project/frontend/src/components/cart/CartPageContent.tsx (modified)
|
||||||
|
project/storefront/src/app/uploads/[...path]/route.ts (modified)
|
||||||
|
project/storefront/src/app/productos/[slug]/page.tsx (modified)
|
||||||
|
project/storefront/src/components/product-card.tsx (modified)
|
||||||
|
```
|
||||||
13
work/artifacts/F-059/leader-close.json
Normal file
13
work/artifacts/F-059/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-059",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "All gates approved. Closing F-059.",
|
||||||
|
"evidence": [
|
||||||
|
"work/artifacts/F-059/reviewer.json verdict=APPROVED",
|
||||||
|
"work/artifacts/F-059/security.json verdict=APPROVED",
|
||||||
|
"work/artifacts/F-059/qa.json verdict=APPROVED",
|
||||||
|
"./scripts/verify.sh exit 0"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:10:00Z"
|
||||||
|
}
|
||||||
17
work/artifacts/F-059/qa.json
Normal file
17
work/artifacts/F-059/qa.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-059",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "End-to-end trace. Live uploads of square, portrait, extreme-portrait, and landscape sources produced thumbnails with the expected dimensions and aspect ratio. Rendered HTML on the frontend no longer contains object-cover or aspect-square strings in product image contexts. No regression on typecheck, build, or service availability.",
|
||||||
|
"evidence": [
|
||||||
|
"AC1 'Backend resize uses fit inside' — diff shows fit:'inside' and MAX_HEIGHT_RATIO=1.4 in all four resize sites",
|
||||||
|
"AC2 '40px and 200px thumbs are never square-cropped from a non-square source' — uploaded 600x800 → 40x53/200x267; 400x1600 → 14x56/70x280; 800x400 → 40x20/200x100; 800x800 → 40x40/200x200. Aspect preserved in every case.",
|
||||||
|
"AC3 'Frontend renders with object-contain, no clipping' — every product image container uses object-contain and a bounded box (aspect-[5/7] max-h-*); curl-grep of the served HTML shows only the new classes",
|
||||||
|
"AC4 'Admin product list shows the full image (not cropped)' — wrapper is w-10 h-14 with object-contain; 40x53 thumbnail is fully visible (no aspect-square crop)",
|
||||||
|
"AC5 'verify.sh is green' — exit 0",
|
||||||
|
"Regression: typecheck and build pass for backend, admin, frontend, storefront",
|
||||||
|
"Regression: services all 200 (backend, frontend, admin, storefront)",
|
||||||
|
"Regression: search-suggest dropdown (Header) and cart line still show the full image; not cropped to a square box"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:10:00Z"
|
||||||
|
}
|
||||||
25
work/artifacts/F-059/reviewer.json
Normal file
25
work/artifacts/F-059/reviewer.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-059",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Two coherent changes. Backend now resizes with sharp fit:'inside' and a 1.4 height ratio, producing non-square thumbnails that fit inside a bounded box. Every frontend product image container was switched from aspect-square + object-cover to aspect-[5/7] + max-h-* + object-contain. No more `object-cover` or `aspect-square` references in product image contexts.",
|
||||||
|
"evidence": [
|
||||||
|
"git diff project/apps/admin/src/app/api/upload/route.ts — MAX_HEIGHT_RATIO constant + fit:'inside' resize",
|
||||||
|
"git diff project/apps/admin/src/app/uploads/[...path]/route.ts — same resize change",
|
||||||
|
"git diff project/frontend/src/app/uploads/[...path]/route.ts — same resize change",
|
||||||
|
"git diff project/storefront/src/app/uploads/[...path]/route.ts — same resize change",
|
||||||
|
"git diff project/frontend/src/app/products/page.tsx — aspect-[5/7] max-h-72 + object-contain",
|
||||||
|
"git diff project/frontend/src/app/products/[slug]/page.tsx — aspect-[5/7] max-h-[500px] + object-contain",
|
||||||
|
"git diff project/frontend/src/app/brands/[slug]/page.tsx, search/page.tsx, categories/[slug]/page.tsx, components/home/FeaturedProducts.tsx — same pattern applied to all card grids",
|
||||||
|
"git diff project/frontend/src/components/checkout/CheckoutClient.tsx, components/cart/CartPageContent.tsx, components/layout/Header.tsx — small thumbnails now use object-contain and /uploads/40/",
|
||||||
|
"git diff project/apps/admin/src/app/(dashboard)/products/page.tsx — w-10 h-14 wrapper with object-contain",
|
||||||
|
"git diff project/apps/admin/src/features/products/components/sections/ImagesSection.tsx — aspect-[5/7] max-h-72 wrapper, object-contain, served from /uploads/200/",
|
||||||
|
"git diff project/storefront/src/app/productos/[slug]/page.tsx, components/product-card.tsx — object-contain + bounded container",
|
||||||
|
"grep -rn 'object-cover|aspect-square' project/{apps/admin,frontend,storefront}/src — zero hits",
|
||||||
|
"curl /products HTML — only aspect-[5/7] and object-contain present",
|
||||||
|
"Live uploads verified: 600x800 -> 40x53 and 200x267; 400x1600 -> 14x56 and 70x280; 800x400 -> 40x20 and 200x100; 800x800 -> 40x40 and 200x200",
|
||||||
|
"npm run typecheck / test / build — green for backend and all 3 Next apps",
|
||||||
|
"./scripts/verify.sh — exit 0"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:10:00Z"
|
||||||
|
}
|
||||||
15
work/artifacts/F-059/security.json
Normal file
15
work/artifacts/F-059/security.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-059",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "No new attack surface. Backend resize is still parameterised and runs server-side; adding max-height does not introduce new input. Frontend change is CSS-only on existing image elements. No new env vars, no new headers, no new routes.",
|
||||||
|
"evidence": [
|
||||||
|
"sharp resize uses parameterised width and a derived maxHeight (no SQL, no user input)",
|
||||||
|
"Frontend change touches CSS className strings only — no new props, no new endpoints, no new auth checks",
|
||||||
|
"Path traversal mitigation in the dynamic /uploads/[...path] handler is unchanged (SAFE_SEGMENT regex still enforced)",
|
||||||
|
"No secrets, no env vars, no new dependencies",
|
||||||
|
"All four resize sites updated identically (no drift between admin/frontend/storefront)",
|
||||||
|
"git diff scope limited to upload routes and frontend CSS classes"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:10:00Z"
|
||||||
|
}
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-058",
|
"feature_id": "F-059",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "fix admin products list missing images",
|
"action": "fix aspect ratio cropping",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "reviewer",
|
"next_agent": "reviewer",
|
||||||
"waiting_for": null,
|
"waiting_for": null,
|
||||||
"updated_at": "2026-08-19T13:10:49Z",
|
"updated_at": "2026-08-19T13:29:22Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-19T08:47:29Z",
|
|
||||||
"agent": "architect",
|
|
||||||
"stage": "design",
|
|
||||||
"state": "done",
|
|
||||||
"message": "items-start + pt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-19T08:47:31Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Inicio build"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-19T08:48:02Z",
|
"ts": "2026-08-19T08:48:02Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
@@ -147,6 +133,20 @@
|
|||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "fix admin products list missing images"
|
"message": "fix admin products list missing images"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-19T13:18:11Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "done",
|
||||||
|
"message": "close all pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-19T13:29:22Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "fix aspect ratio cropping"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_updated": "2026-08-19T09:10:00Z",
|
"last_updated": "2026-08-19T09:10:00Z",
|
||||||
|
|||||||
Reference in New Issue
Block a user