9 Commits

Author SHA1 Message Date
62916a8397 fix(F-08): stronger selected state on hobby picker cards + biome formatting
All checks were successful
CI / ci (push) Successful in 1m13s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 13s
Selected hobby cards now use dark gray fill with inverted white
text/icon for clear visual distinction. Also fixes biome formatting
across all changed files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:37:19 +02:00
596872d942 fix(F-05): use icon button for crop trigger and trash icon for image removal
Changed "Adjust framing" text to a crop icon button visible only in
edit mode. Replaced the X icon on the image remove button with a
trash icon for clearer semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:36:40 +02:00
da5ce7da1d fix(F-06): auto-open crop editor after image upload on item detail
Added onCropChange and dominantColor props to ImageUpload in the item
detail page, so the crop editor opens automatically after uploading
a new image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:35:59 +02:00
452928760a fix(F-01): fix avatar upload persistence on profile page
Replaced the one-shot initialized flag with a dirty flag that allows
the useEffect to re-sync local state from server data after a
successful save. Previously, once initialized was set to true, the
effect never ran again so avatar changes were lost on refetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:35:35 +02:00
957d661567 fix(F-03): pass imageUrl and crop/color props to ItemCard in CollectionView
The flat list was missing dominantColor/crop props, and the grouped
view was also missing imageUrl entirely — causing images not to render
on collection cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:34:47 +02:00
e3124e49c9 fix(F-04): include crop/color fields in item queries and use dominantColor in GearImage
getAllItems and getItemById were not selecting dominantColor, cropZoom,
cropX, cropY from the database. GearImage was ignoring the dominantColor
prop. Now the fields flow end-to-end from DB to UI background fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:34:19 +02:00
581872b534 fix(F-07): add crop/color fields to updateItem service type
The updateItem function's TypeScript type was missing dominantColor,
cropZoom, cropX, and cropY fields, causing crop settings to silently
fail to save despite the Zod schema and DB schema supporting them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:33:28 +02:00
ce48121b2b test(31): complete UAT - 2 passed, 0 issues 2026-04-12 22:23:46 +02:00
2948cc5848 test(30): complete UAT - 3 passed, 1 cosmetic, 3 blocked 2026-04-12 22:22:03 +02:00
9 changed files with 205 additions and 30 deletions

View File

@@ -0,0 +1,70 @@
---
status: partial
phase: 30-onboarding-redesign
source: [30-01-SUMMARY.md, 30-02-SUMMARY.md, 30-03-SUMMARY.md]
started: 2026-04-12T19:30:00Z
updated: 2026-04-12T19:40:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Onboarding triggers on first login
expected: After creating a new account and signing in for the first time, a full-screen onboarding flow appears (not the old small modal wizard).
result: pass
### 2. Welcome step
expected: First screen shows a welcome message with a "Get Started" button. Full-screen, big visuals, immersive feel.
result: pass
### 3. Hobby picker (required step)
expected: Second screen shows hobby cards with icons (Bikepacking, Hiking, Climbing, Cycling, etc.). You can select one or more. This step cannot be skipped.
result: issue
reported: "Works but selected cards need stronger visual distinction — dark gray fill with inverted text/icon instead of just a border change."
severity: cosmetic
### 4. Item browser
expected: After picking a hobby, you see a grid of popular catalog items filtered by that hobby.
result: blocked
blocked_by: server
reason: "Catalog is empty on test server — no items to browse. Needs seed data migration for test env."
### 5. Review screen
expected: After selecting items, a review/summary screen shows all selections grouped by category.
result: blocked
blocked_by: server
reason: "Depends on test 4 — no catalog items to select."
### 6. Completion and collection
expected: After confirming, items are batch-added to collection with auto-created categories.
result: blocked
blocked_by: server
reason: "Depends on test 4 — no catalog items to select."
### 7. Onboarding doesn't show again
expected: Refresh the page or sign out and back in. Onboarding does NOT appear again.
result: pass
## Summary
total: 7
passed: 3
issues: 1
pending: 0
skipped: 0
blocked: 3
## Gaps
- truth: "Selected hobby cards should have strong visual distinction"
status: failed
reason: "User reported: selected cards need dark gray fill with inverted text/icon, not just border change"
severity: cosmetic
test: 3
artifacts:
- src/client/components/onboarding/OnboardingHobbyPicker.tsx
missing:
- Stronger selected state styling (dark bg, inverted colors)

View File

@@ -0,0 +1,33 @@
---
status: complete
phase: 31-mobile-polish
source: [31-01-SUMMARY.md, 31-02-SUMMARY.md]
started: 2026-04-12T19:45:00Z
updated: 2026-04-12T19:45:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Icon action buttons on mobile
expected: Open an item detail page on mobile. Action buttons (Edit, Delete, Duplicate) show as icons only instead of text labels.
result: pass
### 2. Icons across all detail pages
expected: Candidate detail, setup detail, catalog item detail all have icon buttons on mobile too.
result: pass
## Summary
total: 2
passed: 2
issues: 0
pending: 0
skipped: 0
## Gaps
[none]

View File

@@ -231,6 +231,10 @@ export function CollectionView() {
imageUrl={item.imageUrl}
productUrl={item.productUrl}
brand={item.brand}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
))}
</div>
@@ -264,8 +268,13 @@ export function CollectionView() {
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
imageUrl={item.imageUrl}
productUrl={item.productUrl}
brand={item.brand}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
))}
</div>

View File

@@ -12,7 +12,7 @@ interface GearImageProps {
export function GearImage({
src,
alt,
dominantColor: _dominantColor,
dominantColor,
cropZoom,
cropX,
cropY,
@@ -20,6 +20,9 @@ export function GearImage({
cover = false,
}: GearImageProps) {
const hasCrop = cropZoom != null && cropZoom > 1;
const bgStyle = dominantColor
? { backgroundColor: dominantColor }
: undefined;
if (cover) {
return (
@@ -33,24 +36,31 @@ export function GearImage({
if (hasCrop) {
return (
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
<div className="w-full h-full overflow-hidden" style={bgStyle}>
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
</div>
);
}
return (
<img
src={src}
alt={alt}
className={`w-full h-full object-contain ${className}`}
/>
<div
className="w-full h-full flex items-center justify-center"
style={bgStyle}
>
<img
src={src}
alt={alt}
className={`w-full h-full object-contain ${className}`}
/>
</div>
);
}

View File

@@ -125,7 +125,7 @@ export function ImageUpload({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
</svg>
</button>

View File

@@ -12,7 +12,7 @@ export function ProfileSection() {
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
@@ -21,13 +21,12 @@ export function ProfileSection() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (profile && !initialized) {
if (profile && !dirty) {
setDisplayName(profile.displayName ?? "");
setBio(profile.bio ?? "");
setAvatarUrl(profile.avatarUrl ?? null);
setInitialized(true);
}
}, [profile, initialized]);
}, [profile, dirty]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
@@ -38,6 +37,7 @@ export function ProfileSection() {
avatarUrl,
bio: bio.trim() || undefined,
});
setDirty(false);
setMessage({ type: "success", text: "Profile updated" });
} catch (err) {
setMessage({ type: "error", text: (err as Error).message });
@@ -68,6 +68,7 @@ export function ProfileSection() {
try {
const result = await apiUpload<{ filename: string }>("/api/images", file);
setAvatarUrl(result.filename);
setDirty(true);
} catch {
setMessage({ type: "error", text: "Avatar upload failed." });
} finally {
@@ -147,7 +148,10 @@ export function ProfileSection() {
{avatarUrl && (
<button
type="button"
onClick={() => setAvatarUrl(null)}
onClick={() => {
setAvatarUrl(null);
setDirty(true);
}}
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
>
Remove
@@ -175,7 +179,10 @@ export function ProfileSection() {
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onChange={(e) => {
setDisplayName(e.target.value);
setDirty(true);
}}
maxLength={100}
placeholder="Your display name"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
@@ -193,7 +200,10 @@ export function ProfileSection() {
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
onChange={(e) => {
setBio(e.target.value);
setDirty(true);
}}
maxLength={500}
rows={3}
placeholder="Tell others about yourself and your gear interests"

View File

@@ -21,14 +21,26 @@ export function HobbyCard({
onClick={onClick}
className={`w-40 h-40 flex flex-col items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all ${
selected
? "border-gray-700 ring-2 ring-gray-700/20 bg-white border"
? "bg-gray-800 border border-gray-800 ring-2 ring-gray-700/20"
: "bg-gray-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
}`}
>
<LucideIcon name={icon} size={32} className="text-gray-700" />
<LucideIcon
name={icon}
size={32}
className={selected ? "text-white" : "text-gray-700"}
/>
<div className="text-center">
<div className="text-sm font-semibold text-gray-900">{name}</div>
<div className="text-xs text-gray-400">{descriptor}</div>
<div
className={`text-sm font-semibold ${selected ? "text-white" : "text-gray-900"}`}
>
{name}
</div>
<div
className={`text-xs ${selected ? "text-gray-300" : "text-gray-400"}`}
>
{descriptor}
</div>
</div>
</button>
);

View File

@@ -273,9 +273,18 @@ function ItemDetail() {
<ImageUpload
value={form.imageFilename}
imageUrl={imageUrl}
dominantColor={item.dominantColor}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
onCropChange={(crop) => {
updateItem.mutate({
id: item.id,
cropZoom: crop.zoom,
cropX: crop.x,
cropY: crop.y,
});
}}
/>
</div>
) : editingCrop && imageUrl ? (
@@ -328,13 +337,23 @@ function ItemDetail() {
</div>
)}
</div>
{imageUrl && !isEditing && (
{imageUrl && isEditing && !isReference && (
<button
type="button"
onClick={() => setEditingCrop(true)}
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
className="mb-4 w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title="Adjust framing"
>
Adjust framing
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path d="M6.13 1L6 16a2 2 0 0 0 2 2h15" />
<path d="M1 6.13L16 6a2 2 0 0 1 2 2v15" />
</svg>
</button>
)}
</>

View File

@@ -38,6 +38,10 @@ export async function getAllItems(db: Db, userId: number) {
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
cropY: items.cropY,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
@@ -82,6 +86,10 @@ export async function getItemById(db: Db, userId: number, id: number) {
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
cropY: items.cropY,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
@@ -154,6 +162,10 @@ export async function updateItem(
globalItemId: number;
purchasePriceCents: number;
brand: string;
dominantColor: string | null;
cropZoom: number | null;
cropX: number | null;
cropY: number | null;
}>,
) {
// Check if item exists and belongs to user