refine: redesigned layout, flower cards, edit sync, and bouquet preview framing

* refine: move upload mode toggle to top and compact it

* refine: simplify upload layout and remove editorial numbers

* refine: unify flow continue bar and redesign result page layout

* refine: show bouquet flowers as scrollable cards on result page

* refine: add flip-to-Korean on result flower cards

* refine: improve result rationale and sync recipe on edit

* refine: shorten edit title and align bouquet image framing to 3:4
This commit is contained in:
Chaewon Lee
2026-06-14 17:21:20 +09:00
committed by GitHub
parent 4b27c82036
commit 07e4eeaca3
25 changed files with 1303 additions and 409 deletions

View File

@@ -33,7 +33,7 @@
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-cover"
class="aspect-[3/4] h-auto w-full object-contain object-center"
/>
</div>
{:else}

View File

@@ -3,6 +3,6 @@
</script>
<div class="w-64 max-w-full flex-none border border-line-strong bg-white px-4 py-3 shadow-sm lg:px-6 lg:py-5">
<h3 class="text-sm font-semibold">{title}</h3>
<p class="mt-2 text-xs leading-snug">{description}</p>
<h3 class="text-sm leading-snug font-semibold">{title}</h3>
<p class="mt-2 text-xs leading-relaxed">{description}</p>
</div>

View File

@@ -0,0 +1,21 @@
<script module>
export const FLOW_CONTINUE_BUTTON =
'w-full px-2 py-3 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50 lg:w-auto';
</script>
<script>
/**
* Fixed bottom on mobile, bottom-right of the right panel on desktop.
* Pair with a section using `pb-[3.75rem] lg:pb-8` (taller pb on edit if the bar has extra controls).
*/
let { class: klass = '', children } = $props();
</script>
<div
class={[
'fixed right-0 bottom-0 left-0 z-20 space-y-2 bg-placeholder/30 px-4 pb-5 lg:static lg:flex lg:shrink-0 lg:flex-col lg:items-end lg:bg-transparent lg:px-6 lg:pb-0',
klass
]}
>
{@render children()}
</div>

View File

@@ -1,4 +1,5 @@
<script>
import { resolve } from '$app/paths';
import logoUrl from '$lib/assets/logo.svg';
// `step` is 1-based; the matching dot is highlighted as the current step.
@@ -8,10 +9,14 @@
</script>
<header class="flex items-center justify-between border-b border-line px-6 py-5 md:px-10">
<div class="flex items-center gap-3">
<a
href={resolve('/')}
class="flex cursor-pointer items-center gap-3"
aria-label="AI Florist home"
>
<img src={logoUrl} alt="" class="size-7 shrink-0 translate-y-px" aria-hidden="true" />
<span class="text-lg leading-none tracking-wide">AI Florist</span>
</div>
</a>
<div class="flex items-center gap-3 sm:gap-4">
{#each dots as dot (dot)}

View File

@@ -42,7 +42,7 @@
{/if}
</h1>
<p class="text-sm text-muted">
{style ?? ''} | ₩{budget.toLocaleString('ko-KR')}
{style ?? '...'} | ₩{budget.toLocaleString('ko-KR')}
</p>
{/if}
</header>

View File

@@ -0,0 +1,131 @@
<script>
import flowerIconUrl from '$lib/assets/flower.svg';
let {
name,
nameKo,
wordOfFlower,
wordOfFlowerKo,
imageSrc,
role = 'main'
} = $props();
let flipped = $state(false);
const roleLabel = $derived(
role === 'main' ? 'Main' : role === 'greenery' ? 'Greenery' : 'Filler'
);
const roleLabelKo = $derived(
role === 'main' ? '메인' : role === 'greenery' ? '그리너리' : '필러'
);
function toggleFlip() {
flipped = !flipped;
}
</script>
<button
type="button"
class="flip-card h-[16.25rem] w-40 shrink-0 snap-start cursor-pointer border-none bg-transparent p-0 text-left"
aria-label={flipped ? `${nameKo} card, show English` : `${name} card, show Korean`}
onclick={toggleFlip}
>
<div class="flip-card-inner h-full" class:is-flipped={flipped}>
<article
class="flip-card-face flex h-full flex-col overflow-hidden rounded-2xl border border-line-strong bg-white shadow-sm"
>
<div class="flex h-6 shrink-0 items-center gap-1.5 px-3 pt-3">
<img src={flowerIconUrl} alt="" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="text-xs leading-none text-ink">{roleLabel}</span>
</div>
<div class="relative mx-2 mt-2 min-h-0 flex-1">
<img
src={imageSrc}
alt={name}
class="h-full w-full object-contain object-bottom"
loading="lazy"
/>
</div>
<div class="shrink-0 px-3 pb-4 pt-2">
<h3
class="flex min-h-8 items-center justify-center text-center text-sm leading-tight tracking-wide text-ink"
>
<span class="line-clamp-2">{name}</span>
</h3>
<p class="line-clamp-2 text-center text-[0.6875rem] leading-snug text-ink">
{wordOfFlower}
</p>
</div>
</article>
<article
class="flip-card-face flip-card-back flex h-full flex-col overflow-hidden rounded-2xl border border-line-strong bg-white shadow-sm"
aria-hidden={!flipped}
>
<div class="flex h-6 shrink-0 items-center gap-1.5 px-3 pt-3">
<img src={flowerIconUrl} alt="" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="text-xs leading-none text-ink">{roleLabelKo}</span>
</div>
<div class="relative mx-2 mt-2 min-h-0 flex-1">
<img
src={imageSrc}
alt={nameKo}
class="h-full w-full object-contain object-bottom"
loading="lazy"
/>
</div>
<div class="shrink-0 px-3 pb-4 pt-2">
<h3
class="flex min-h-8 items-center justify-center text-center text-sm leading-tight tracking-wide text-ink"
>
<span class="line-clamp-2">{nameKo}</span>
</h3>
<p class="line-clamp-2 text-center text-[0.6875rem] leading-snug text-ink">
{wordOfFlowerKo}
</p>
</div>
</article>
</div>
</button>
<style>
.flip-card {
perspective: 1000px;
}
.flip-card-inner {
position: relative;
width: 100%;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0.2, 0.2, 1);
}
.flip-card-inner.is-flipped {
transform: rotateY(180deg);
}
.flip-card-face {
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-card-back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
.flip-card-inner {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,29 @@
<script>
import BouquetFlowerCard from './BouquetFlowerCard.svelte';
/** @type {{ id: number, name: string, nameKo: string, wordOfFlower: string, wordOfFlowerKo: string, imageSrc: string, role?: 'main' | 'sub' | 'greenery' }[]} */
let { flowers = [] } = $props();
</script>
{#if flowers.length === 0}
<p class="text-sm text-muted">Flower details will appear once the bouquet recipe is ready.</p>
{:else}
<div class="min-h-0 w-full">
<p class="mb-4 text-xs tracking-[0.2em] text-muted uppercase">Flowers in your bouquet</p>
<div
class="flex snap-x snap-mandatory gap-4 overflow-x-auto px-0.5 py-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each flowers as flower (flower.id)}
<BouquetFlowerCard
name={flower.name}
nameKo={flower.nameKo}
wordOfFlower={flower.wordOfFlower}
wordOfFlowerKo={flower.wordOfFlowerKo}
imageSrc={flower.imageSrc}
role={flower.role ?? 'main'}
/>
{/each}
</div>
</div>
{/if}

View File

@@ -4,11 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let {
primaryFile = $bindable(null),
uploadedTiles = $bindable(),
caption = 'build their moodboard!'
} = $props();
let { primaryFile = $bindable(null), uploadedTiles = $bindable() } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
@@ -58,170 +54,28 @@
</script>
<div class="moodboard min-h-0 w-full flex-1">
<div class="collage">
<span class="mood-number number-color">(01)</span>
<span class="mood-number number-season">(02)</span>
<span class="mood-number number-character">(03)</span>
<span class="mood-number number-location">(04)</span>
<span class="mood-caption">{caption}</span>
<UploadTile
label="Color"
bind:file={colorFile}
class="moodboard-tile tile-color"
/>
<UploadTile
label="Season"
bind:file={seasonFile}
class="moodboard-tile tile-season"
/>
<UploadTile
label="Character"
bind:file={characterFile}
class="moodboard-tile tile-character"
/>
<UploadTile
label="Location"
bind:file={locationFile}
class="moodboard-tile tile-location"
/>
</div>
<UploadTile label="Color" bind:file={colorFile} class="tile tile-color" />
<UploadTile label="Season" bind:file={seasonFile} class="tile tile-season" />
<UploadTile label="Character" bind:file={characterFile} class="tile tile-character" />
<UploadTile label="Location" bind:file={locationFile} class="tile tile-location" />
</div>
<style>
.moodboard {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
.collage {
position: relative;
width: min(100%, 34rem);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
width: 100%;
max-width: 32rem;
height: 100%;
aspect-ratio: 4 / 5.2;
max-height: 44rem;
margin: 0 auto;
padding: 0.75rem 1.5rem 0;
}
.moodboard :global(.moodboard-tile) {
position: absolute;
background: #fff;
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
}
/* 01 — top-left portrait */
:global(.tile-color) {
top: 8%;
left: 4%;
width: 30%;
aspect-ratio: 3 / 4;
}
/* 02 — right portrait, dips below the top of 01 */
:global(.tile-season) {
top: 13%;
right: 3%;
width: 29%;
aspect-ratio: 3 / 4;
}
/* 03 — landscape, lower-left */
:global(.tile-character) {
top: 49%;
left: 10%;
width: 36%;
aspect-ratio: 4 / 3;
}
/* 04 — bottom-right portrait */
:global(.tile-location) {
top: 62%;
right: 4%;
width: 29%;
aspect-ratio: 3 / 4;
}
.mood-number,
.mood-caption {
position: absolute;
z-index: 2;
pointer-events: none;
color: var(--color-ink);
}
.mood-number {
font-size: clamp(1rem, 2.2vw, 1.5rem);
line-height: 1;
}
.number-color {
top: 13%;
left: 35%;
}
.number-season {
top: 38%;
left: 60%;
}
.number-character {
top: 73%;
left: 9%;
}
.number-location {
top: 58%;
right: 11%;
}
.mood-caption {
/* horizontally centered over the toggle capsule (the left flex child of
the bottom bar), not the section. Its center sits left of the collage
midpoint because the "Continue" button occupies the bar's right side. */
left: 29%;
top: 84%;
font-size: clamp(0.85rem, 1.7vw, 1.1rem);
transform: translateX(-50%);
white-space: nowrap;
}
@media (max-width: 767px) {
.moodboard {
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
.collage {
display: grid;
width: 100%;
aspect-ratio: auto;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.moodboard :global(.moodboard-tile) {
position: relative;
inset: auto;
width: 100%;
height: auto;
}
:global(.tile-color),
:global(.tile-season),
:global(.tile-location) {
aspect-ratio: 3 / 4;
}
:global(.tile-character) {
aspect-ratio: 4 / 3;
}
.mood-number,
.mood-caption {
display: none;
}
.moodboard :global(.tile) {
min-height: 0;
height: 100%;
width: 100%;
}
</style>

View File

@@ -4,8 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null), hasImage = $bindable(), caption = 'upload their feed!' } =
$props();
let { primaryFile = $bindable(null), hasImage = $bindable() } = $props();
let firstFile = $state(null);
@@ -36,12 +35,7 @@
</script>
<div class="feed min-h-0 w-full flex-1">
<div class="sns-collage">
<span class="sns-number">(01)</span>
<span class="sns-caption">{caption}</span>
<UploadTile bind:file={firstFile} class="sns-tile" />
</div>
<UploadTile bind:file={firstFile} class="sns-tile" />
</div>
<style>
@@ -49,76 +43,15 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
.sns-collage {
position: relative;
width: min(100%, 42rem);
height: 100%;
aspect-ratio: 4 / 5;
max-height: 34rem;
padding: 0.75rem 1.5rem 0;
}
.feed :global(.sns-tile) {
position: absolute;
top: 12%;
left: 50%;
width: 58%;
height: 46%;
background: #fff;
box-shadow: 0 10px 24px rgb(56 50 47 / 0.08);
transform: translateX(-50%);
}
.sns-number,
.sns-caption {
position: absolute;
z-index: 2;
pointer-events: none;
color: var(--color-ink);
}
.sns-number {
top: 6%;
left: 23%;
font-size: clamp(1rem, 2.2vw, 1.5rem);
line-height: 1;
}
.sns-caption {
left: 50%;
bottom: 13%;
font-size: clamp(0.9rem, 1.9vw, 1.25rem);
transform: translateX(-50%);
white-space: nowrap;
}
@media (max-width: 767px) {
.feed {
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
.sns-collage {
width: 100%;
aspect-ratio: auto;
min-height: 0;
}
.feed :global(.sns-tile) {
position: relative;
inset: auto;
width: 100%;
height: auto;
aspect-ratio: 4 / 5;
transform: none;
}
.sns-number,
.sns-caption {
display: none;
}
height: 100%;
max-height: 100%;
width: auto;
max-width: min(20rem, 100%);
aspect-ratio: 4 / 5;
}
</style>