refine: ui, prompt, and desc card

* ui improved

* prompt:realisic+noHuman

* prompt:editRefinement

* fix: map DescriptionCard truncation and truncateAt typo

Prevent result/map card overflow with character limits and line-clamp; fix buildMapOrderDescription calling undefined truncateAt.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chaewon Lee
2026-06-16 10:27:27 +09:00
committed by GitHub
parent 71da3f2c17
commit 3e0ff5df70
28 changed files with 1321 additions and 312 deletions

View File

@@ -0,0 +1,25 @@
/**
* 2분할 좌측 액자 슬롯 — 페이지 전환 시 동일 위치·크기 유지.
* DescriptionCard가 커져도 액자 슬롯(flex-none)은 위치·크기 고정, 카드만 아래로 늘어남.
*/
/**
* 액자 전용 슬롯 — 프레임 높이만큼만 차지 (데스크톱에서 불필요한 빈 높이 제거)
* 모바일 row 레이아웃용 최소 높이만 유지
*/
export const ARTWORK_SLOT_FLOWER =
'flex h-[11rem] w-full shrink-0 flex-none items-start justify-center sm:h-[13rem] lg:h-auto lg:min-h-0';
/**
* 액자 + DescriptionCard 래퍼
* lg:pt-16 — 우측 ContextForm 헤더(lg:py-16)와 상단 정렬 (레퍼런스 기준)
* 세로 중앙 배치(calc(50%...)) 없음 → 카드 길이와 무관하게 액자 Y 고정
*/
export const ARTWORK_SLOT_WRAPPER =
'mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 pt-6 pb-8 lg:flex-col lg:items-center lg:justify-start lg:gap-5 lg:px-10 lg:pt-20 lg:pb-10';
/** 액자 외곽 최대 너비 */
export const ARTWORK_FRAME_MAX_W = 'w-full max-w-24 sm:max-w-28 lg:max-w-75';
/** DescriptionCard — 액자 바로 아래, flex-none으로 카드만 세로 확장 */
export const ARTWORK_SLOT_CARD = 'w-full min-w-0 shrink-0 flex-none lg:flex lg:justify-center';

View File

@@ -0,0 +1,86 @@
import {
FRAME_FILL,
FRAME_STROKE,
INNER_H,
INNER_W,
MAT_X,
MAT_Y,
OUTER_H,
OUTER_W
} from './museumFrameGeometry.js';
/**
* p5 캔버스에 미술관 화이트 액자 링을 그립니다.
* 중앙 개구부는 투명 — 아래 HTML 슬롯이 보입니다.
* @param {import('p5')} p
* @param {number} canvasW
* @param {number} canvasH
*/
export function drawMuseumFrame(p, canvasW, canvasH) {
p.clear();
const scale = Math.min(canvasW / OUTER_W, canvasH / OUTER_H);
const drawW = OUTER_W * scale;
const drawH = OUTER_H * scale;
const offsetX = (canvasW - drawW) / 2;
const offsetY = (canvasH - drawH) / 2;
const matX = MAT_X * scale;
const matY = MAT_Y * scale;
const innerW = INNER_W * scale;
const innerH = INNER_H * scale;
const innerX = offsetX + matX;
const innerY = offsetY + matY;
const strokeW = Math.max(1, scale);
p.push();
p.fill(FRAME_FILL);
p.noStroke();
// mat 링 (상·하·좌·우 4조각)
p.rect(offsetX, offsetY, drawW, matY);
p.rect(offsetX, offsetY + matY + innerH, drawW, matY);
p.rect(offsetX, offsetY + matY, matX, innerH);
p.rect(offsetX + matX + innerW, offsetY + matY, matX, innerH);
// 외곽·내곽 테두리
p.noFill();
p.stroke(FRAME_STROKE);
p.strokeWeight(strokeW);
p.rect(offsetX, offsetY, drawW, drawH);
p.rect(innerX, innerY, innerW, innerH);
p.pop();
}
/**
* p5 instance mode 스케치 팩토리.
* @param {HTMLElement} container
* @returns {Promise<import('p5')>}
*/
export function createMuseumFrameSketch(container) {
return new Promise((resolve) => {
import('p5').then(({ default: p5 }) => {
const sketch = (/** @type {import('p5')} */ p) => {
p.setup = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
const canvas = p.createCanvas(w, h);
canvas.parent(container);
canvas.elt.style.pointerEvents = 'none';
p.pixelDensity(Math.min(window.devicePixelRatio || 1, 2));
drawMuseumFrame(p, w, h);
};
p.windowResized = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
p.resizeCanvas(w, h);
drawMuseumFrame(p, w, h);
};
};
resolve(new p5(sketch, container));
});
});
}

View File

@@ -0,0 +1,29 @@
/**
* white frame.svg 비율을 768×1024 내부 개구부에 맞춘 논리 좌표.
* SVG 외곽 206×280, 내부 179.29×253.17, mat ~13.3px.
*/
/** 꽃다발 이미지 출력 크기 (3:4) */
export const INNER_W = 768;
export const INNER_H = 1024;
/** inner 대비 mat 두께 (SVG 비율 환산) */
export const MAT_X = Math.round((13.3574 / 179.2866) * INNER_W);
export const MAT_Y = Math.round((13.2979 / 253.1691) * INNER_H);
export const OUTER_W = INNER_W + MAT_X * 2;
export const OUTER_H = INNER_H + MAT_Y * 2;
export const FRAME_STROKE = '#D5D5D5';
export const FRAME_FILL = '#FFFFFF';
/** ref/frame ref.png 시각 기준 — 개구부 안 아트워크 너비 비율 (액자 크기는 그대로) */
export const ARTWORK_INNER_SCALE = 0.85;
/** CSS % 배치용 (0100) */
export const APERTURE_LEFT_PCT = (MAT_X / OUTER_W) * 100;
export const APERTURE_TOP_PCT = (MAT_Y / OUTER_H) * 100;
export const APERTURE_WIDTH_PCT = (INNER_W / OUTER_W) * 100;
export const APERTURE_HEIGHT_PCT = (INNER_H / OUTER_H) * 100;
export const OUTER_ASPECT_RATIO = `${OUTER_W} / ${OUTER_H}`;

View File

@@ -7,16 +7,13 @@
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="#7D7D7D" stroke-width="6"/>
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="url(#paint0_linear_391_391)" stroke-width="6"/>
<foreignObject x="137" y="68" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="147" y="78" width="91" height="71" fill="#F1C130"/>
<foreignObject x="116.507" y="33.507" width="60.9719" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
<foreignObject x="128.304" y="59.3043" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_2_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="147" y="78" width="43" height="43" fill="#F19430"/>
<rect x="147" y="78" width="91" height="71" fill="#F1C130"/>
<rect x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
<rect x="147" y="78" width="43" height="43" fill="#F19430"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<defs>
<clipPath id="bgblur_0_391_391_clip_path" transform="translate(-137 -68)"><rect x="147" y="78" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_391_clip_path" transform="translate(-116.507 -33.507)"><rect x="122" y="39" width="49.9859" height="39"/>
</clipPath><clipPath id="bgblur_2_391_391_clip_path" transform="translate(-128.304 -59.3043)"><rect x="147" y="78" width="43" height="43"/>
</clipPath><linearGradient id="paint0_linear_391_391" x1="234.304" y1="63" x2="234.304" y2="316.5" gradientUnits="userSpaceOnUse">
<linearGradient id="paint0_linear_391_391" x1="234.304" y1="63" x2="234.304" y2="316.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,5 +1,7 @@
<script>
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { seedDevFlow } from '$lib/flowerFlow/devSeed.js';
/** 나중에 mute 하려면 true 로 변경 */
@@ -25,19 +27,46 @@
// 페이지 상단 const jobId = getFlowString(...) 갱신을 위해 새로고침
location.reload();
}
async function openResultDev() {
loading = true;
message = '';
const result = await seedDevFlow('result');
if (!result.ok) {
message = result.error;
loading = false;
return;
}
loading = false;
await goto(resolve('/result'));
}
</script>
{#if dev && !DEV_SEED_MUTED}
<div class="dev-seed fixed bottom-4 left-4 z-50 flex flex-col items-start gap-1">
<button
type="button"
disabled={loading}
onclick={fillDevData}
class="rounded border border-dashed border-subtle/60 bg-surface/95 px-3 py-1.5 text-xs text-muted shadow-sm backdrop-blur hover:border-subtle hover:text-ink disabled:opacity-50"
title="AI 없이 더미 job + sessionStorage 채우기 (개발용)"
>
{loading ? 'Seeding…' : 'Dev: Fill data'}
</button>
<div class="flex flex-wrap gap-1">
<button
type="button"
disabled={loading}
onclick={fillDevData}
class="rounded border border-dashed border-subtle/60 bg-surface/95 px-3 py-1.5 text-xs text-muted shadow-sm backdrop-blur hover:border-subtle hover:text-ink disabled:opacity-50"
title="AI 없이 더미 job + sessionStorage 채우기 (개발용)"
>
{loading ? 'Seeding…' : 'Dev: Fill data'}
</button>
<button
type="button"
disabled={loading}
onclick={openResultDev}
class="rounded border border-dashed border-subtle/60 bg-surface/95 px-3 py-1.5 text-xs text-muted shadow-sm backdrop-blur hover:border-subtle hover:text-ink disabled:opacity-50"
title="더미 job 생성 후 /result 로 이동 (개발용)"
>
{loading ? 'Seeding…' : 'Dev: → Result'}
</button>
</div>
{#if message && message !== 'Filled'}
<p class="max-w-48 rounded bg-surface/95 px-2 py-1 text-xs text-red-600 shadow-sm">
{message}

View File

@@ -1,9 +1,14 @@
<script>
// The exhibited artwork — always shown on the left, acting like a step indicator.
import Vase from './Vase.svelte';
import DescriptionCard from './DescriptionCard.svelte';
import ComingSoonTape from './ComingSoonTape.svelte';
import MuseumFrame from './MuseumFrame.svelte';
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
import {
ARTWORK_SLOT_FLOWER,
ARTWORK_SLOT_WRAPPER,
ARTWORK_SLOT_CARD
} from '$lib/artwork/artworkSlotLayout.js';
let {
title = 'Title',
@@ -23,6 +28,8 @@
let downloading = $state(false);
let downloadError = $state('');
const frameMode = $derived(imageSrc ? 'bouquet' : 'artwork');
async function handleDownload() {
if (!downloadImage || downloading) return;
@@ -45,41 +52,27 @@
<!--
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
-->
<div
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 pt-6 pb-8 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pt-[calc(50%-5rem)] lg:pb-12"
>
<div
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
>
{#if imageSrc}
<div class="mx-auto flex w-full max-w-24 shrink-0 flex-col items-center sm:max-w-28 lg:max-w-75">
<div class="w-full overflow-hidden">
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-contain object-center"
/>
</div>
{#if downloadImage}
<button
type="button"
disabled={downloading}
onclick={handleDownload}
class="mt-2 text-xs text-muted underline-offset-4 hover:text-ink hover:underline disabled:opacity-50"
>
{downloading ? 'Downloading...' : 'Download image'}
</button>
{#if downloadError}
<p class="mt-1 text-center text-[0.65rem] text-red-600">{downloadError}</p>
{/if}
<div class={ARTWORK_SLOT_WRAPPER}>
<div class={ARTWORK_SLOT_FLOWER}>
<div class="mx-auto flex w-full flex-col items-center">
<MuseumFrame mode={frameMode} {variant} {imageSrc} imageAlt="Selected bouquet" />
{#if imageSrc && downloadImage}
<button
type="button"
disabled={downloading}
onclick={handleDownload}
class="mt-2 text-xs text-muted underline-offset-4 hover:text-ink hover:underline disabled:opacity-50"
>
{downloading ? 'Downloading...' : 'Download image'}
</button>
{#if downloadError}
<p class="mt-1 text-center text-[0.65rem] text-red-600">{downloadError}</p>
{/if}
</div>
{:else}
<Vase {variant} />
{/if}
{/if}
</div>
</div>
<div class="min-w-0 shrink-0 lg:flex lg:w-full lg:justify-center">
<div class={ARTWORK_SLOT_CARD}>
<DescriptionCard {title} {description} mode={cardMode} />
</div>
</div>

View File

@@ -20,7 +20,7 @@
</h3>
<p
class={[
'mt-2 text-xs',
'mt-2 line-clamp-4 text-xs',
mode === 'instruction' ? 'leading-snug text-muted' : 'leading-relaxed text-ink'
]}
>

View File

@@ -0,0 +1,94 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { createMuseumFrameSketch } from '$lib/artwork/drawMuseumFrame.js';
import {
APERTURE_HEIGHT_PCT,
APERTURE_LEFT_PCT,
APERTURE_TOP_PCT,
APERTURE_WIDTH_PCT,
ARTWORK_INNER_SCALE,
OUTER_ASPECT_RATIO
} from '$lib/artwork/museumFrameGeometry.js';
import { ARTWORK_FRAME_MAX_W } from '$lib/artwork/artworkSlotLayout.js';
import { getArtworkSrc } from './artworkVariants.js';
let {
/** @type {'bouquet' | 'artwork'} */
mode = 'artwork',
/** @type {import('./artworkVariants.js').ArtworkVariant} */
variant = 'create1',
imageSrc = null,
imageAlt = 'Selected bouquet',
/** bouquet 로딩 플레이스홀더 */
loading = false
} = $props();
const artworkSrc = $derived(getArtworkSrc(variant));
/** @type {HTMLElement | null} */
let frameHost = $state(null);
/** @type {import('p5') | null} */
let p5Instance = $state(null);
onMount(() => {
if (!browser || !frameHost) return;
let cancelled = false;
createMuseumFrameSketch(frameHost).then((instance) => {
if (cancelled) {
instance.remove();
return;
}
p5Instance = instance;
});
return () => {
cancelled = true;
p5Instance?.remove();
p5Instance = null;
};
});
</script>
<div class="mx-auto shrink-0 {ARTWORK_FRAME_MAX_W}">
<div class="relative w-full" style:aspect-ratio={OUTER_ASPECT_RATIO}>
<!-- 개구부: 꽃다발 또는 아트워크 -->
<div
class="absolute z-0 flex items-center justify-center overflow-hidden bg-surface"
style:left="{APERTURE_LEFT_PCT}%"
style:top="{APERTURE_TOP_PCT}%"
style:width="{APERTURE_WIDTH_PCT}%"
style:height="{APERTURE_HEIGHT_PCT}%"
>
{#if mode === 'bouquet'}
{#if loading}
<div class="h-full w-full animate-pulse bg-placeholder"></div>
{:else if imageSrc}
<img
src={imageSrc}
alt={imageAlt}
class="h-full w-full object-cover object-center"
draggable="false"
/>
{:else}
<div class="h-full w-full bg-placeholder"></div>
{/if}
{:else}
{#key variant}
<img
src={artworkSrc}
alt=""
class="h-auto max-h-full w-auto object-contain object-center"
style:width="{ARTWORK_INNER_SCALE * 100}%"
draggable="false"
/>
{/key}
{/if}
</div>
<!-- p5 액자 링 (고정, 내용과 무관) -->
<div bind:this={frameHost} class="absolute inset-0 z-10" aria-hidden="true"></div>
</div>
</div>

View File

@@ -7,10 +7,11 @@
const src = $derived(getArtworkSrc(variant));
</script>
<!-- MuseumFrame artwork 모드에서 크기·위치 제어. 단독 사용 시 fallback. -->
<img
{src}
alt=""
class="mx-auto h-auto w-full max-w-24 shrink-0 sm:max-w-28 lg:max-w-75"
class="mx-auto h-auto w-full object-contain object-center"
width="328"
height="443"
/>

View File

@@ -10,7 +10,7 @@
type="button"
onclick={() => onchange(option)}
class={[
'text-xl tracking-wide transition-colors',
'text-lg tracking-wide transition-colors',
selected === option ? 'text-ink' : 'text-muted hover:text-ink'
]}
>

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import FlowNav from '$lib/components/ui/FlowNav.svelte';
import Button from '$lib/components/ui/Button.svelte';
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
function handleStart() {
@@ -10,25 +10,28 @@
</script>
<section
class="relative flex min-h-dvh flex-col bg-surface font-sans text-ink"
class="relative flex min-h-dvh flex-col bg-surface px-6 py-8 font-sans text-ink sm:px-10 sm:py-10 lg:px-14"
aria-label="Every bouquet starts with a muse — seed to bouquet growth metaphor"
>
<FlowNav showBack={false} continueLabel="Start Creating ->" onContinue={handleStart} />
<div class="mx-auto flex w-full max-w-6xl min-h-0 flex-1 flex-col justify-center">
<GrowthMetaphorIllustration />
<div class="flex flex-1 flex-col px-6 pt-8 pb-8 sm:px-10 sm:pt-10 sm:pb-10 lg:px-14">
<div class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col justify-center">
<GrowthMetaphorIllustration />
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
Every Bouquet Starts with a Muse
</p>
</div>
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
Every Bouquet Starts with a Muse
</p>
</div>
<!--
아트워크 하단 선과 동일한 max-w-6xl 기준:
Fleumuse — 선 왼쪽 끝 / start creating — 선 오른쪽 끝, y축 가운데 정렬
-->
<div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-6 pt-10 pb-2">
<h1 class="text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
Fleumuse
</h1>
<div class="mx-auto w-full max-w-6xl pt-10 pb-2">
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
Fleumuse
</h1>
<div class="shrink-0">
<Button onclick={handleStart}>start creating</Button>
</div>
</div>
</section>

View File

@@ -52,12 +52,12 @@
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
: '';
const phone = shop.phone
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.phone)}</p>`
? `<p style="display:block;margin:4px 0 0;font-size:12px;color:#666;word-break:break-all;overflow-wrap:anywhere">${escapeHtml(shop.phone)}</p>`
: '';
return `<div style="padding:15px 12px;min-width:200px;max-width:240px;font-family:system-ui,sans-serif;line-height:1.4">
<p style="margin:0;font-size:15px;font-weight:600;color:#1a1a1a">${escapeHtml(shop.name)}</p>
<p style="margin:4px 0 0;font-size:13px;color:#555">${escapeHtml(shop.address)}</p>
return `<div style="display:block;padding:12px;min-width:0;width:min(260px,72vw);max-width:72vw;box-sizing:border-box;font-family:system-ui,sans-serif;line-height:1.45;overflow:visible;word-wrap:break-word;overflow-wrap:anywhere;white-space:normal">
<p style="display:block;margin:0;font-size:15px;font-weight:600;color:#38322f;word-break:break-word;overflow-wrap:anywhere">${escapeHtml(shop.name)}</p>
<p style="display:block;margin:4px 0 0;font-size:13px;color:#555;word-break:break-word;overflow-wrap:anywhere">${escapeHtml(shop.address)}</p>
${distance}
${phone}
</div>`;

View File

@@ -48,8 +48,8 @@
</script>
<div class="flex min-h-0 flex-1 flex-col">
<header class="shrink-0 px-6 py-8 md:px-10 lg:px-12 lg:py-10">
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
<header class="shrink-0 px-6 pt-8 pb-3 md:px-10 lg:px-12 lg:pt-10 lg:pb-4">
<h1 class="text-2xl leading-relaxed font-light text-ink md:text-3xl lg:text-[2rem]">
Find a nearby florist
</h1>
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
@@ -59,7 +59,6 @@
{#if mock}
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
{/if}
<div class="mt-6 border-b border-pill lg:mt-8"></div>
</header>
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
@@ -94,7 +93,7 @@
</button>
</div>
<div class="w-full shrink-0 lg:w-72 lg:overflow-y-auto">
<div class="w-full min-w-0 shrink-0 lg:w-72 lg:overflow-y-auto">
{#if loading && shops.length === 0}
<p class="text-sm text-muted">Searching for flower shops...</p>
{:else}

View File

@@ -2,25 +2,29 @@
let { shops = [], selectedId = $bindable(null), onselect } = $props();
</script>
<div class="flex flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2">
{#each shops as shop (shop.id)}
<button
type="button"
onclick={() => onselect(shop.id)}
class={[
'rounded border px-4 py-3 text-left transition-colors',
'box-border h-auto w-full min-w-0 overflow-hidden rounded border px-4 py-3 text-left transition-colors',
selectedId === shop.id
? 'border-pill bg-pill text-surface'
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
]}
>
<p class="text-sm font-medium">{shop.name}</p>
<p class="mt-1 text-xs opacity-80">{shop.address}</p>
<p class="text-sm leading-snug font-medium [overflow-wrap:anywhere] break-words">
{shop.name}
</p>
<p class="mt-1 text-xs leading-snug [overflow-wrap:anywhere] break-words opacity-80">
{shop.address}
</p>
{#if shop.distance}
<p class="mt-1 text-xs opacity-70">{shop.distance}</p>
<p class="mt-1 text-xs leading-snug break-words opacity-70">{shop.distance}</p>
{/if}
{#if selectedId === shop.id && shop.phone}
<p class="mt-1 text-xs opacity-70">{shop.phone}</p>
<p class="mt-1 text-xs leading-snug break-all opacity-70">{shop.phone}</p>
{/if}
</button>
{:else}

View File

@@ -65,10 +65,30 @@
</script>
<div class="moodboard min-h-0 w-full flex-1">
<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" />
<UploadTile
label="Fashion"
prompt="What outfit reminds you of them?"
bind:file={colorFile}
class="tile tile-color"
/>
<UploadTile
label="Season"
prompt="What season reminds you of them?"
bind:file={seasonFile}
class="tile tile-season"
/>
<UploadTile
label="Location"
prompt="What place matches their vibe?"
bind:file={characterFile}
class="tile tile-character"
/>
<UploadTile
label="Cafe"
prompt="What kind of cafe feels like them?"
bind:file={locationFile}
class="tile tile-location"
/>
</div>
<style>

View File

@@ -40,7 +40,11 @@
</script>
<div class="feed min-h-0 w-full flex-1">
<UploadTile bind:file={firstFile} class="sns-tile" />
<UploadTile
prompt="Upload screenshots of their social feed"
bind:file={firstFile}
class="sns-tile"
/>
</div>
<style>

View File

@@ -5,6 +5,8 @@
// both the moodboard and the SNS feed.
let {
label = null,
/** 빈 타일 중앙에 표시할 안내 문장 */
prompt = null,
showLabel = true,
class: klass = '',
style = '',
@@ -44,7 +46,7 @@
type="file"
accept="image/*"
class="sr-only"
aria-label={label ? `Add a ${label} image` : 'Add an image'}
aria-label={prompt ?? (label ? `Add a ${label} image` : 'Add an image')}
onchange={pick}
/>
@@ -63,13 +65,15 @@
</span>
{:else}
<div
class="flex flex-col items-center gap-3 text-subtle transition-transform group-hover:scale-105"
class="flex flex-col items-center gap-3 px-4 text-center text-subtle transition-transform group-hover:scale-105"
>
<span
class="flex size-10 items-center justify-center rounded-full border border-current text-xl leading-none"
class="flex size-10 shrink-0 items-center justify-center rounded-full border border-current text-xl leading-none"
aria-hidden="true">+</span
>
{#if label && showLabel}
{#if prompt}
<span class="max-w-[14rem] text-sm leading-snug text-muted">{prompt}</span>
{:else if label && showLabel}
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
{/if}
</div>

View File

@@ -0,0 +1,178 @@
/** @typedef {'ribbon/bow' | 'wrapping paper' | 'flower' | 'leaf' | 'stem' | 'selected object'} AreaEditTarget */
/** @typedef {{ x: number, y: number }} PercentPoint */
/**
* @typedef {Object} AreaEditIntent
* @property {AreaEditTarget} targetObject
* @property {string} normalizedPrompt
* @property {boolean} isColorChange
* @property {PercentPoint} selectionCentroid
* @property {{ minX: number, maxX: number, minY: number, maxY: number }} selectionBounds
*/
/**
* @param {PercentPoint[]} selection
* @returns {{ minX: number, maxX: number, minY: number, maxY: number, centroid: PercentPoint }}
*/
export function analyzeSelectionGeometry(selection) {
let minX = 100;
let maxX = 0;
let minY = 100;
let maxY = 0;
let sumX = 0;
let sumY = 0;
for (const point of selection) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
sumX += point.x;
sumY += point.y;
}
const count = Math.max(selection.length, 1);
return {
minX,
maxX,
minY,
maxY,
centroid: { x: sumX / count, y: sumY / count }
};
}
/**
* @param {string} userPrompt
*/
export function isVagueColorChangePrompt(userPrompt) {
const lower = userPrompt.trim().toLowerCase();
return (
/color|colour|색|톤|tone|hue|shade|tint|darken|lighten|black|white|red|blue|green|pink|gold|silver|ivory|navy|beige|brown|gray|grey|다른|변경|바꿔|바꾸|칠|색상/.test(
lower
) &&
!/replace|swap|add|remove|delete|more|less|volume|romantic|warm|bigger|smaller|꽃|flower|rose|tulip|ribbon|bow|wrapping|paper|leaf|stem|리본|포장|줄기|잎/.test(
lower
)
);
}
/**
* @param {string} userPrompt
*/
export function isColorChangePrompt(userPrompt) {
const lower = userPrompt.trim().toLowerCase();
return /color|colour|색|톤|tone|hue|shade|tint|darken|lighten|black|white|red|blue|green|pink|gold|silver|ivory|navy|beige|brown|gray|grey|다른|변경|바꿔|바꾸|칠|색상/.test(
lower
);
}
/**
* @param {string} userPrompt
* @param {PercentPoint} centroid
* @returns {AreaEditTarget | null}
*/
function inferTargetFromPromptAndPosition(userPrompt, centroid) {
const lower = userPrompt.trim().toLowerCase();
if (/ribbon|bow|리본|보우/.test(lower)) return 'ribbon/bow';
if (/wrapping|wrap paper|paper wrap|포장|싸개|포장지/.test(lower)) return 'wrapping paper';
if (/flower|bloom|petal|rose|tulip|꽃|장미|튤립/.test(lower)) return 'flower';
if (/leaf|foliage|greenery|잎|잎사귀|그린/.test(lower)) return 'leaf';
if (/stem|줄기|가지/.test(lower)) return 'stem';
if (centroid.y >= 58 && centroid.x >= 30 && centroid.x <= 70 && isColorChangePrompt(userPrompt)) {
return 'ribbon/bow';
}
if (centroid.y <= 45) return 'flower';
if (centroid.y >= 70 && centroid.x >= 25 && centroid.x <= 75) return 'wrapping paper';
return null;
}
/**
* @param {AreaEditTarget} targetObject
*/
function materialPreserveSuffix(targetObject) {
if (targetObject === 'ribbon/bow') {
return ' Preserve the ribbon folds, highlights, shadows, and fabric texture.';
}
if (targetObject === 'wrapping paper') {
return ' Preserve paper creases, shadows, and wrapping shape.';
}
if (targetObject === 'flower' || targetObject === 'leaf' || targetObject === 'stem') {
return ' Preserve natural petal or plant texture, shadows, and edges.';
}
return ' Preserve the object shape, texture, shadows, and highlights.';
}
/**
* @param {string} userPrompt
* @param {AreaEditTarget} targetObject
*/
export function normalizeAreaEditPrompt(userPrompt, targetObject) {
const trimmed = userPrompt.trim();
const lower = trimmed.toLowerCase();
const label = targetObject === 'selected object' ? 'selected object' : `the ${targetObject}`;
const blackMatch = lower.match(
/(?:change|make|turn|set|to|into|색.*?)?\s*(?:color|colour|색)?\s*(?:to|into|as|를|을)?\s*black|검정|검은/
);
if (blackMatch) {
return `Change only ${label} color to black.${materialPreserveSuffix(targetObject)}`;
}
if (/use other color|different color|another color|other colour|different colour|다른 색|다른색|색 바꿔|색상 변경/.test(lower)) {
return `Change only ${label} to a different harmonious color.${materialPreserveSuffix(targetObject)}`;
}
if (/^change color$|^change colour$|^change the color$|^change the colour$|^색 변경$|^색 바꿔$/.test(lower)) {
return `Change only ${label} color.${materialPreserveSuffix(targetObject)}`;
}
if (isVagueColorChangePrompt(trimmed)) {
return `Change only ${label} color.${materialPreserveSuffix(targetObject)}`;
}
if (targetObject === 'ribbon/bow' && isColorChangePrompt(trimmed)) {
return `Change only ${label} color as requested: ${trimmed}.${materialPreserveSuffix(targetObject)}`;
}
return trimmed;
}
/**
* @param {{
* userPrompt: string,
* selection: PercentPoint[],
* imageWidth?: number,
* imageHeight?: number
* }} options
* @returns {AreaEditIntent}
*/
export function inferAreaEditTarget(options) {
const { userPrompt, selection } = options;
const geometry = analyzeSelectionGeometry(selection);
const inferred = inferTargetFromPromptAndPosition(userPrompt, geometry.centroid);
const targetObject = inferred ?? 'selected object';
const normalizedPrompt = normalizeAreaEditPrompt(userPrompt, targetObject);
return {
targetObject,
normalizedPrompt,
isColorChange: isColorChangePrompt(userPrompt),
selectionCentroid: geometry.centroid,
selectionBounds: {
minX: geometry.minX,
maxX: geometry.maxX,
minY: geometry.minY,
maxY: geometry.maxY
}
};
}

View File

@@ -5,6 +5,14 @@ export const BOUQUET_IMAGE_ASPECT = '3:4';
export const BOUQUET_IMAGE_ASPECT_PROMPT =
'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping stems or wrapping.';
/** 최초 generate prompt opening — catalog 톤·장면 설정 */
export const BOUQUET_CATALOG_SCENE_PROMPT =
'A professional florist product photograph of a handcrafted bouquet, photographed for a premium flower shop catalog.';
/** generate + whole edit 공통 — 인물/손 노출 방지 */
export const BOUQUET_NO_PERSON_CONSTRAINT =
'Bouquet only. No person. No hands. No body parts visible.';
/**
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
*/
@@ -47,6 +55,7 @@ export function formatStrictRecipeConstraints(recipe) {
'- Include EVERY listed flower without omission — each must be clearly visible; none may be missing, hidden, or left out',
'- Do not swap or substitute any listed species unless the edit request explicitly requires that change',
'- Real cut flowers only; no fantasy colors or impossible hybrids',
`- ${BOUQUET_NO_PERSON_CONSTRAINT}`,
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
].join('\n');
@@ -60,6 +69,7 @@ export function formatStrictRecipeConstraints(recipe) {
export function formatStrictBouquetImagePrompt(recipe) {
return [
'Generate a realistic Korean florist bouquet product photo.',
BOUQUET_CATALOG_SCENE_PROMPT,
'',
formatStrictRecipeConstraints(recipe)
].join('\n');
@@ -72,37 +82,40 @@ export function formatStrictBouquetImagePrompt(recipe) {
* mode?: string,
* selection?: Array<{ x: number, y: number }>,
* recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
* recipeChanged?: boolean
* recipeChanged?: boolean,
* targetObject?: string,
* normalizedPrompt?: string
* }} options
* @returns {string}
*/
export function formatBouquetEditPrompt(options) {
const { userPrompt, mode, selection, recipe, recipeChanged = false } = options;
const {
userPrompt,
mode,
selection,
recipe,
recipeChanged = false,
targetObject = 'selected object',
normalizedPrompt = userPrompt
} = options;
const isAreaEdit = mode === 'area' && selection && selection.length >= 3;
if (isAreaEdit) {
return [
'You are editing the attached florist bouquet photograph with a binary mask.',
'This is a localized inpainting edit — NOT a full bouquet redesign or re-render.',
'You are editing a realistic bouquet product photo.',
'The transparent mask is only a rough localization guide.',
'Do NOT fill the entire masked shape.',
'Identify the actual object inside the selected area that matches the user request.',
"Edit only that object's visible surface.",
"Preserve the object's original shape, folds, shadows, highlights, texture, and boundaries.",
'Preserve all unselected objects exactly: flowers, leaves, stems, wrapping paper, background, lighting, and composition.',
'If the request is a color change, recolor only the target object material while keeping realistic shading.',
'Do not add new flowers, new ribbon, new objects, text, hands, or people.',
'Do not paint a flat solid color block inside the mask.',
'',
`Edit request (masked region only): ${userPrompt}`,
'',
'How to edit inside the mask:',
'- Apply the edit request only to whatever is inside the transparent mask region (flowers, ribbon, wrapping, foliage, etc.)',
'- The request may be a color/style tweak OR a content swap — e.g. replace blooms in this area with roses, change ribbon color, adjust wrapping',
'- When swapping flowers inside the mask, render the requested species naturally in that region; blend stems, lighting, and edges with the surrounding bouquet',
'- Keep realistic material detail — petal texture, fabric folds, paper creases, shadows, and lighting — seamless with the rest of the photo',
'- Do not paste a flat color block; the edited area should look naturally photographed',
'- Do not use solid black unless the user explicitly asked for black',
'',
'Mask rules (mandatory):',
'- Transparent pixels in the attached mask = the ONLY area you may change',
'- Opaque pixels in the mask = leave completely unchanged',
'- Do NOT recolor, restyle, brighten, blur, regenerate, or swap species outside the mask',
'- Do NOT apply the edit request to the whole image',
'',
'Preserve everywhere outside the mask:',
'- All flowers, foliage, wrapping, ribbon, background, lighting, and framing exactly as in the input photo',
`User request: ${userPrompt}`,
`Inferred target object: ${targetObject}`,
`Normalized edit instruction: ${normalizedPrompt}`,
'',
'Output exactly one edited photo. No before/after collage.'
].join('\n');

View File

@@ -10,12 +10,254 @@
const EMPTY_LOCALE = /** @type {OrderMessageLocale} */ ({ plainText: '', segments: [] });
/** @type {Record<string, string>} */
const MOOD_EN_TO_KO_STEM = {
warm: '따뜻',
romantic: '로맨틱',
gentle: '부드러',
soft: '부드러',
elegant: '우아',
vibrant: '생기 있는',
sentimental: '감성적',
cozy: '아늑',
fresh: '싱그러',
natural: '자연스러',
cheerful: '밝',
calm: '차분',
dreamy: '몽환적',
bold: '선명',
delicate: '섬세',
minimal: '미니멀',
classic: '클래식',
playful: '발랄',
modern: '모던',
organic: '내추럴',
pastel: '파스텔톤',
muted: '차분',
lively: '활기찬'
};
/** @type {Record<string, string>} */
const MOOD_KO_STEM_TO_EN = {
따뜻: 'warm',
로맨틱: 'romantic',
부드러: 'gentle',
우아: 'elegant',
'생기 있는': 'vibrant',
감성적: 'sentimental',
아늑: 'cozy',
싱그러: 'fresh',
자연스러: 'natural',
: 'cheerful',
차분: 'calm',
몽환적: 'dreamy',
선명: 'bold',
섬세: 'delicate',
미니멀: 'minimal',
클래식: 'classic',
발랄: 'playful',
모던: 'modern',
내추럴: 'natural',
파스텔톤: 'pastel',
활기찬: 'lively'
};
const HANGUL_RE = /[\u3131-\u318E\uAC00-\uD7A3]/;
/**
* @param {string[]} [items]
* @param {string} fallback
* @param {string} value
*/
function joinKeywords(items, fallback) {
return items?.length ? items.slice(0, 4).join(', ') : fallback;
function isHangul(value) {
return HANGUL_RE.test(value);
}
/**
* @param {string} relationship
* @param {string | undefined} style
*/
function relationshipPhraseEn(relationship, style) {
const who = relationship?.trim() || 'Someone special';
const styleLower = style?.toLowerCase();
switch (who) {
case 'Friend':
return 'my friend';
case 'Family':
return 'my family';
case 'Partner':
if (styleLower === 'feminine') return 'my girlfriend';
if (styleLower === 'masculine') return 'my boyfriend';
return 'my partner';
case 'Teacher':
return 'my teacher';
case 'Others':
return 'someone special';
default:
return isHangul(who) ? who : `my ${who.toLowerCase()}`;
}
}
/**
* @param {string} relationship
* @param {string | undefined} style
*/
function relationshipPhraseKo(relationship, style) {
const who = relationship?.trim() || '소중한 사람';
const styleLower = style?.toLowerCase();
if (isHangul(who)) return who;
switch (who) {
case 'Friend':
return '친구';
case 'Family':
return '가족';
case 'Partner':
if (styleLower === 'feminine') return '여자친구';
if (styleLower === 'masculine') return '남자친구';
return '연인';
case 'Teacher':
return '선생님';
case 'Others':
return '소중한 사람';
default:
return who;
}
}
/**
* @param {number | undefined} won
*/
function formatBudgetKo(won) {
if (!won || !Number.isFinite(won)) return '유연한 예산';
if (won >= 10_000 && won % 10_000 === 0) return `${won / 10_000}만원`;
return `${won.toLocaleString('ko-KR')}`;
}
/**
* @param {number | undefined} won
*/
function formatBudgetEn(won) {
if (!won || !Number.isFinite(won)) return 'a flexible budget';
const dollars = Math.max(1, Math.round(won / 1_400));
return `$${dollars}`;
}
/**
* @param {string} keyword
*/
function toKoMoodStem(keyword) {
const trimmed = keyword.trim();
if (!trimmed) return '';
if (isHangul(trimmed)) {
return trimmed.replace(/적인$/, '적').replace(/한$/, '').replace(/운$/, '');
}
return MOOD_EN_TO_KO_STEM[trimmed.toLowerCase()] ?? trimmed;
}
/**
* @param {string} stem
*/
function finalizeKoMoodStem(stem) {
if (!stem) return '';
if (stem.endsWith('적')) return `${stem}`;
if (stem.endsWith(' 있는')) return `${stem}`;
return `${stem}`;
}
/**
* @param {string} keyword
*/
function toEnMoodKeyword(keyword) {
const trimmed = keyword.trim();
if (!trimmed) return '';
if (isHangul(trimmed)) {
const stem = trimmed.replace(/적인$/, '적').replace(/한$/, '').replace(/운$/, '');
return MOOD_KO_STEM_TO_EN[stem] ?? trimmed;
}
return trimmed.toLowerCase();
}
/**
* @param {string[]} keywords
* @param {'en' | 'ko'} lang
*/
function buildMoodPhrase(keywords, lang) {
const picked = keywords.map((item) => item.trim()).filter(Boolean).slice(0, 2);
if (!picked.length) {
return lang === 'ko' ? '따뜻하고 감성적인' : 'warm and romantic';
}
if (lang === 'ko') {
const stems = picked.map((keyword) => toKoMoodStem(keyword)).filter(Boolean);
if (!stems.length) return '따뜻하고 감성적인';
if (stems.length === 1) return finalizeKoMoodStem(stems[0]);
return `${stems[0]}하고 ${finalizeKoMoodStem(stems[1])}`;
}
const translated = picked.map((keyword) => toEnMoodKeyword(keyword)).filter(Boolean);
if (!translated.length) return 'warm and romantic';
return translated.length === 1 ? translated[0] : `${translated[0]} and ${translated[1]}`;
}
/**
* @param {MoodAnalysis | null | undefined} moodAnalysis
* @param {BouquetRecipe | null | undefined} recipe
*/
function collectMoodKeywords(moodAnalysis, recipe) {
return [
...(moodAnalysis?.moodKeywords ?? []),
...(moodAnalysis?.styleImpression ?? []),
...(moodAnalysis?.textureKeywords ?? []),
...(recipe?.colors?.slice(0, 1) ?? [])
];
}
/**
* @param {string} plainText
* @param {string[]} highlights
* @returns {OrderMessageSegment[]}
*/
function buildSegments(plainText, highlights) {
const unique = [...new Set(highlights.filter(Boolean))].sort((a, b) => b.length - a.length);
if (!unique.length) return [{ text: plainText, highlight: false }];
/** @type {OrderMessageSegment[]} */
const segments = [];
let cursor = 0;
while (cursor < plainText.length) {
let nextIndex = -1;
let matched = '';
for (const term of unique) {
const index = plainText.indexOf(term, cursor);
if (index === -1) continue;
if (nextIndex === -1 || index < nextIndex) {
nextIndex = index;
matched = term;
}
}
if (nextIndex === -1 || !matched) {
segments.push({ text: plainText.slice(cursor), highlight: false });
break;
}
if (nextIndex > cursor) {
segments.push({ text: plainText.slice(cursor, nextIndex), highlight: false });
}
segments.push({ text: matched, highlight: true });
cursor = nextIndex + matched.length;
}
return segments.length ? segments : [{ text: plainText, highlight: false }];
}
/**
@@ -33,72 +275,35 @@ export function buildFloristOrderMessage(input) {
return { ...EMPTY_LOCALE, ko: { ...EMPTY_LOCALE } };
}
const relationship = userInput?.relationship ?? 'someone special';
const occasion = userInput?.occasion ?? 'a special occasion';
const budget = userInput?.budget
? `${Number(userInput.budget).toLocaleString('en-US')}`
: 'a flexible range';
const relationship = userInput?.relationship ?? 'Someone special';
const style = userInput?.style;
const budget = userInput?.budget;
const moodFeel = joinKeywords(
[
...(moodAnalysis?.moodKeywords ?? []),
...(moodAnalysis?.styleImpression ?? []),
...(moodAnalysis?.textureKeywords ?? [])
],
'gentle and warm'
);
const moodKeywords = collectMoodKeywords(moodAnalysis, recipe);
const moodEn = buildMoodPhrase(moodKeywords, 'en');
const moodKo = buildMoodPhrase(moodKeywords, 'ko');
const colorTone = joinKeywords(
[...(moodAnalysis?.colorPalette ?? []), ...(recipe?.colors ?? [])],
'soft natural'
);
const relEn = relationshipPhraseEn(relationship, style);
const relKo = relationshipPhraseKo(relationship, style);
const budgetEn = formatBudgetEn(budget);
const budgetKo = formatBudgetKo(budget);
const plainText =
`Hello, I'd like to inquire about a flower order. ` +
`It's a bouquet for ${relationship} for ${occasion}, with a budget around ${budget}. ` +
`I'd like to gift something with a ${moodFeel} feel, using ${colorTone} tones. ` +
`Would a reservation be possible?`;
const segments = [
{
text: "Hello, I'd like to inquire about a flower order. It's a bouquet for ",
highlight: false
},
{ text: relationship, highlight: true },
{ text: ' for ', highlight: false },
{ text: occasion, highlight: true },
{ text: ', with a budget around ', highlight: false },
{ text: budget, highlight: true },
{ text: ". I'd like to gift something with a ", highlight: false },
{ text: moodFeel, highlight: true },
{ text: ' feel, using ', highlight: false },
{ text: colorTone, highlight: true },
{ text: ' tones. Would a reservation be possible?', highlight: false }
];
`Hi! I'd like to order a bouquet for ${relEn}. ` +
`My budget is around ${budgetEn}, and I'm looking for something ${moodEn}. ` +
`Would it be possible to create a bouquet inspired by the reference images below?`;
const koPlainText =
`안녕하세요, 꽃 주문 문의드립니다. ` +
`${relationship}에게 ${occasion} 꽃다발을 준비하고 싶습니다. 예산은 약 ${budget}니다. ` +
`${moodFeel}한 분위기로, ${colorTone} 톤으로 선물하고 싶습니다. ` +
`예약 가능할까요?`;
const koSegments = [
{ text: '안녕하세요, 꽃 주문 문의드립니다. ', highlight: false },
{ text: relationship, highlight: true },
{ text: '에게 ', highlight: false },
{ text: occasion, highlight: true },
{ text: ' 꽃다발을 준비하고 싶습니다. 예산은 약 ', highlight: false },
{ text: budget, highlight: true },
{ text: '입니다. ', highlight: false },
{ text: moodFeel, highlight: true },
{ text: '한 분위기로, ', highlight: false },
{ text: colorTone, highlight: true },
{ text: ' 톤으로 선물하고 싶습니다. 예약 가능할까요?', highlight: false }
];
`안녕하세요! ${relKo}에게 선물할 꽃다발 주문하려고 합니다. ` +
`예산은 ${budgetKo} 정도이고, ${moodKo} 무드로 제작 부탁드립니다. ` +
`아래 레퍼런스 이미지와 비슷한 느낌으로 가능할까요?`;
return {
plainText,
segments,
ko: { plainText: koPlainText, segments: koSegments }
segments: buildSegments(plainText, [relEn, budgetEn, moodEn]),
ko: {
plainText: koPlainText,
segments: buildSegments(koPlainText, [relKo, budgetKo, moodKo])
}
};
}

View File

@@ -170,42 +170,26 @@ export function truncateDescription(text, maxLength = 140) {
}
/**
* One-line context for the map order card (mood, recipient, or recipe concept).
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
* @param {{ concept?: string } | null | undefined} recipe
* Map DescriptionCard — 1~2줄 작품 설명 (꽃 종류 + 넓은 꽃말 테마).
* @param {string | null | undefined} word
* @returns {string | null}
*/
function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
const recipient = userInput?.relationship?.trim();
const mood = pickKeywords(
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
2
);
const hasCardMessage = Boolean(extractCardMessage(userInput));
if (hasCardMessage && recipient) {
return `A bouquet for ${recipient}, shaped around your card message`;
}
if (hasCardMessage) {
return 'A bouquet shaped around your card message';
}
if (mood && recipient) {
return `A ${mood} bouquet for ${recipient}`;
}
if (mood) {
return `A ${mood} bouquet from your moodboard`;
}
if (recipe?.concept?.trim()) {
return recipe.concept.trim();
}
if (recipient) {
return `A custom bouquet for ${recipient}`;
}
return 'Your custom bouquet design';
function broadFlowerTheme(word) {
if (!word?.trim()) return null;
const w = word.toLowerCase();
if (/love|romance|passion|devotion|사랑|연애|로맨/.test(w)) return 'love';
if (/friend|companionship|우정|친구/.test(w)) return 'friendship';
if (/health|healing|vitality|건강|회복/.test(w)) return 'health';
if (/thank|grateful|감사/.test(w)) return 'gratitude';
if (/joy|happy|celebrat|cheer|기쁨|축하/.test(w)) return 'joy';
if (/hope|faith|희망/.test(w)) return 'hope';
if (/warm|tender|gentle|따뜻|부드러/.test(w)) return 'warmth';
if (/peace|calm|평화|차분/.test(w)) return 'peace';
return null;
}
/**
* Map order card — short intro plus flower species (main → sub → greenery, capped).
* Map order card — brief artwork-style blurb (flowers + broad themes, max ~2 lines).
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], concept?: string } | null | undefined} recipe
* @param {{
* moodAnalysis?: { moodKeywords?: string[], styleImpression?: string[] } | null,
@@ -214,16 +198,30 @@ function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
* }} [options]
*/
export function buildMapOrderDescription(recipe, options = {}) {
const { moodAnalysis = null, userInput = null, maxFlowers = 4 } = options;
const { maxFlowers = 3 } = options;
const flowers = resolveRecipeFlowers(recipe, () => '').slice(0, maxFlowers);
if (flowers.length === 0) {
return 'Your selected bouquet design.';
return 'A hand-tied bouquet shaped from your moodboard.';
}
const intro = buildMapOrderIntro(moodAnalysis, userInput, recipe);
const flowerList = flowers.map((flower) => flower.name).join(', ');
const names = flowers.map((flower) => flower.name).join(', ');
const themes = [
...new Set(
flowers
.flatMap((flower) => [
broadFlowerTheme(flower.wordOfFlower),
broadFlowerTheme(flower.wordOfFlowerKo)
])
.filter(Boolean)
)
].slice(0, 2);
return `${intro}: ${flowerList}.`;
if (themes.length === 0) {
return truncateDescription(`Featuring ${names}.`, 120);
}
return truncateDescription(`Featuring ${names} — notes of ${themes.join(' and ')}.`, 140);
}
/**
@@ -286,13 +284,22 @@ function getPrimaryFlowerFromRecipe(recipe) {
return matchCatalogFlower(label);
}
/** result/map DescriptionCard 본문 최대 글자수 */
export const ARTWORK_DESCRIPTION_MAX_LENGTH = 120;
/**
* Why this bouquet fits — mood from images, message, and main flower language.
* @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
* @param {number} [maxLength=ARTWORK_DESCRIPTION_MAX_LENGTH]
*/
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
export function buildBouquetRationale(
moodAnalysis,
userInput,
recipe,
maxLength = ARTWORK_DESCRIPTION_MAX_LENGTH
) {
const normalized = normalizeRecipeLists(recipe ?? {});
const recipient = userInput?.relationship?.trim();
const subject = recipient ? `${recipient}'s` : 'The';
@@ -330,12 +337,15 @@ export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
}
if (parts.length === 0) {
return recipient
? `This bouquet reflects the feeling in ${recipient}'s images.`
: 'This bouquet reflects the feeling in the images.';
return truncateDescription(
recipient
? `This bouquet reflects the feeling in ${recipient}'s images.`
: 'This bouquet reflects the feeling in the images.',
maxLength
);
}
return parts.join(' ');
return truncateDescription(parts.join(' '), maxLength);
}
/**

View File

@@ -0,0 +1,195 @@
import sharp from 'sharp';
import {
readImageDimensions,
buildEditMaskRgba,
countEditablePixels,
encodeOpenAIMaskPng,
erodeEditMask
} from './selectionMask.js';
/**
* @param {number} r
* @param {number} g
* @param {number} b
*/
function isBackgroundPixel(r, g, b) {
return r > 240 && g > 240 && b > 240;
}
/**
* @param {number} r
* @param {number} g
* @param {number} b
*/
function isStrongFoliagePixel(r, g, b) {
return g > r + 18 && g > b + 18 && g > 70;
}
/**
* @param {number} ar
* @param {number} ag
* @param {number} ab
* @param {number} br
* @param {number} bg
* @param {number} bb
*/
function colorDistance(ar, ag, ab, br, bg, bb) {
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
}
/**
* @param {Uint8Array} sourceRgba
* @param {Uint8Array} editMaskRgba
* @param {number} width
* @param {number} height
* @param {number} cx
* @param {number} cy
*/
function medianSeedColor(sourceRgba, editMaskRgba, width, height, cx, cy) {
/** @type {number[]} */
const reds = [];
/** @type {number[]} */
const greens = [];
/** @type {number[]} */
const blues = [];
const radius = Math.max(4, Math.round(Math.min(width, height) * 0.02));
for (let y = Math.max(0, cy - radius); y <= Math.min(height - 1, cy + radius); y += 1) {
for (let x = Math.max(0, cx - radius); x <= Math.min(width - 1, cx + radius); x += 1) {
const index = (y * width + x) * 4;
if (editMaskRgba[index + 3] !== 0) continue;
const r = sourceRgba[index];
const g = sourceRgba[index + 1];
const b = sourceRgba[index + 2];
if (isBackgroundPixel(r, g, b)) continue;
reds.push(r);
greens.push(g);
blues.push(b);
}
}
if (reds.length === 0) return null;
const mid = Math.floor(reds.length / 2);
reds.sort((a, b) => a - b);
greens.sort((a, b) => a - b);
blues.sort((a, b) => a - b);
return { r: reds[mid], g: greens[mid], b: blues[mid] };
}
/**
* @param {Uint8Array} sourceRgba
* @param {Uint8Array} editMaskRgba
* @param {number} width
* @param {number} height
* @param {{ r: number, g: number, b: number }} seed
* @param {boolean} excludeFoliage
* @param {number} threshold
*/
function buildColorAffinityMask(
sourceRgba,
editMaskRgba,
width,
height,
seed,
excludeFoliage,
threshold
) {
const refined = new Uint8Array(editMaskRgba.length);
refined.set(editMaskRgba);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
if (editMaskRgba[index + 3] !== 0) {
refined[index] = 255;
refined[index + 1] = 255;
refined[index + 2] = 255;
refined[index + 3] = 255;
continue;
}
const r = sourceRgba[index];
const g = sourceRgba[index + 1];
const b = sourceRgba[index + 2];
const matches =
!isBackgroundPixel(r, g, b) &&
(!excludeFoliage || !isStrongFoliagePixel(r, g, b)) &&
colorDistance(r, g, b, seed.r, seed.g, seed.b) <= threshold;
if (!matches) {
refined[index] = 255;
refined[index + 1] = 255;
refined[index + 2] = 255;
refined[index + 3] = 255;
}
}
}
return refined;
}
/**
* 사용자 polygon을 ROI 힌트로만 쓰고, 축소·색상 유사도로 refined OpenAI mask 생성.
* @param {{ base64: string, mimeType: string }} sourceImage
* @param {Array<{ x: number, y: number }>} selection
* @param {import('$lib/flowerFlow/areaEditIntent.js').AreaEditIntent} editContext
*/
export async function buildRefinedAreaEditMask(sourceImage, selection, editContext) {
const buffer = Buffer.from(sourceImage.base64, 'base64');
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
const rawMask = buildEditMaskRgba(width, height, selection);
const bboxW =
((editContext.selectionBounds.maxX - editContext.selectionBounds.minX) / 100) * width;
const bboxH =
((editContext.selectionBounds.maxY - editContext.selectionBounds.minY) / 100) * height;
const erodeRadius = Math.max(3, Math.round(Math.min(bboxW, bboxH) * 0.12));
let refined = erodeEditMask(rawMask, width, height, erodeRadius);
const erodedCount = countEditablePixels(refined);
if (editContext.isColorChange && erodedCount > 0) {
const sourceRgba = await sharp(buffer).ensureAlpha().raw().toBuffer();
const cx = Math.round((editContext.selectionCentroid.x / 100) * width);
const cy = Math.round((editContext.selectionCentroid.y / 100) * height);
const seed = medianSeedColor(sourceRgba, refined, width, height, cx, cy);
if (seed) {
const excludeFoliage =
editContext.targetObject === 'ribbon/bow' ||
editContext.targetObject === 'wrapping paper';
const threshold = editContext.targetObject === 'wrapping paper' ? 42 : 55;
const colorMask = buildColorAffinityMask(
sourceRgba,
refined,
width,
height,
seed,
excludeFoliage,
threshold
);
const colorCount = countEditablePixels(colorMask);
if (colorCount >= Math.max(24, Math.round(erodedCount * 0.15))) {
refined = colorMask;
}
}
}
const maskBuffer = encodeOpenAIMaskPng(width, height, refined);
return {
base64: maskBuffer.toString('base64'),
mimeType: 'image/png',
width,
height,
editPixelCount: countEditablePixels(refined),
rawPolygonPixelCount: countEditablePixels(rawMask)
};
}

View File

@@ -88,7 +88,7 @@ export function readImageDimensions(buffer, mimeType) {
}
/** @param {number} width @param {number} height @param {Uint8Array} rgba */
function encodePng(width, height, rgba) {
export function encodeOpenAIMaskPng(width, height, rgba) {
/** @type {number[]} */
const rows = [];
let stride = 0;
@@ -143,12 +143,12 @@ function crc32(data) {
}
/**
* OpenAI mask: transparent pixels = edit region, opaque = preserve.
* OpenAI mask RGBA: alpha 0 = edit region, opaque white = preserve.
* @param {number} width
* @param {number} height
* @param {Array<{ x: number, y: number }>} selection
* @param {Array<{ x: number, y: number }>} selection percent coords
*/
export function buildOpenAIEditMask(width, height, selection) {
export function buildEditMaskRgba(width, height, selection) {
const polygon = closePolygon(
selection.map((point) => ({
x: (point.x / 100) * width,
@@ -175,7 +175,71 @@ export function buildOpenAIEditMask(width, height, selection) {
}
}
return encodePng(width, height, rgba);
return rgba;
}
/** @param {Uint8Array} rgba */
export function countEditablePixels(rgba) {
let count = 0;
for (let i = 3; i < rgba.length; i += 4) {
if (rgba[i] === 0) count += 1;
}
return count;
}
/**
* @param {Uint8Array} maskRgba
* @param {number} width
* @param {number} height
* @param {number} radius
*/
export function erodeEditMask(maskRgba, width, height, radius) {
const eroded = new Uint8Array(maskRgba.length);
eroded.set(maskRgba);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
if (maskRgba[index + 3] !== 0) continue;
let keep = true;
for (let dy = -radius; dy <= radius && keep; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (dx * dx + dy * dy > radius * radius) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
keep = false;
break;
}
const neighborIndex = (ny * width + nx) * 4;
if (maskRgba[neighborIndex + 3] !== 0) {
keep = false;
break;
}
}
}
if (!keep) {
eroded[index] = 255;
eroded[index + 1] = 255;
eroded[index + 2] = 255;
eroded[index + 3] = 255;
}
}
}
return eroded;
}
/**
* OpenAI mask: transparent pixels = edit region, opaque = preserve.
* @param {number} width
* @param {number} height
* @param {Array<{ x: number, y: number }>} selection
*/
export function buildOpenAIEditMask(width, height, selection) {
return encodeOpenAIMaskPng(width, height, buildEditMaskRgba(width, height, selection));
}
/**

View File

@@ -1,12 +1,17 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { loadGeneratedImageBytes } from '$lib/server/flowerFlow/loadGeneratedImage.js';
import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js';
import { buildRefinedAreaEditMask } from '$lib/server/flowerFlow/refinedAreaMask.js';
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
import { inferAreaEditTarget } from '$lib/flowerFlow/areaEditIntent.js';
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
import { frameToBouquetOutput } from '$lib/server/openai/bouquetImageFrame.js';
import {
BOUQUET_OUTPUT_HEIGHT,
BOUQUET_OUTPUT_WIDTH,
frameToBouquetOutput
} from '$lib/server/openai/bouquetImageFrame.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
@@ -43,14 +48,6 @@ function editForJob(jobId, job, instruction) {
const task = (async () => {
const priorRecipe = normalizeRecipeLists(job.recipe);
const recipeEditPrompt =
instruction.mode === 'area'
? `Localized masked edit (selected region of the photo only): ${instruction.prompt}`
: instruction.prompt;
const updatedRecipe = normalizeRecipeLists(
await applyRecipeEdit(job.recipe, recipeEditPrompt)
);
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
const normalizedBytes = await frameToBouquetOutput(
@@ -62,21 +59,51 @@ function editForJob(jobId, job, instruction) {
mimeType: 'image/png'
};
const areaEditContext =
instruction.mode === 'area'
? inferAreaEditTarget({
userPrompt: instruction.prompt,
selection: instruction.selection,
imageWidth: BOUQUET_OUTPUT_WIDTH,
imageHeight: BOUQUET_OUTPUT_HEIGHT
})
: null;
const recipeEditPrompt =
instruction.mode === 'area' && areaEditContext
? `Localized masked edit (${areaEditContext.targetObject} only): ${areaEditContext.normalizedPrompt}`
: instruction.prompt;
const updatedRecipe = normalizeRecipeLists(
await applyRecipeEdit(job.recipe, recipeEditPrompt)
);
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
const editPrompt = formatBouquetEditPrompt({
userPrompt: instruction.prompt,
mode: instruction.mode,
selection: instruction.selection,
recipe: updatedRecipe,
recipeChanged
recipeChanged,
targetObject: areaEditContext?.targetObject,
normalizedPrompt: areaEditContext?.normalizedPrompt
});
const mask =
instruction.mode === 'area' && instruction.selection.length >= 3
? buildAreaEditMask(normalizedSource, instruction.selection)
instruction.mode === 'area' && instruction.selection.length >= 3 && areaEditContext
? await buildRefinedAreaEditMask(
normalizedSource,
instruction.selection,
areaEditContext
)
: null;
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${
mask
? ` (refined mask target=${areaEditContext?.targetObject} editPx=${mask.editPixelCount}/${mask.rawPolygonPixelCount})`
: ''
} → editing...`
);
const generatedImage = await editBouquetImage(normalizedSource, editPrompt, { mask });
const images = await uploadGeneratedImages(

View File

@@ -3,9 +3,15 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
import MuseumFrame from '$lib/components/ui/Artwork/MuseumFrame.svelte';
import FlowNav from '$lib/components/ui/FlowNav.svelte';
import EditComposerBar from '$lib/components/ui/edit/EditComposerBar.svelte';
import Header from '$lib/components/ui/Header.svelte';
import {
ARTWORK_SLOT_FLOWER,
ARTWORK_SLOT_WRAPPER,
ARTWORK_SLOT_CARD
} from '$lib/artwork/artworkSlotLayout.js';
import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
@@ -335,36 +341,25 @@
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={5} total={7} />
<FlowNav
backHref="/message"
onContinue={continueToResult}
continueDisabled={editing}
/>
<FlowNav backHref="/message" onContinue={continueToResult} continueDisabled={editing} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<section
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8 lg:pb-12"
class="relative flex min-h-0 w-full shrink-0 flex-col border-b border-line lg:h-full lg:min-h-0 lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
>
<div
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6"
>
<div
class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75"
>
{#if loading}
<div class="aspect-[3/4] w-full animate-pulse bg-placeholder"></div>
{:else if imageSrc}
<img
src={imageSrc}
alt="Generated bouquet"
class="aspect-[3/4] w-full object-contain object-center"
/>
{:else}
<div class="aspect-[3/4] w-full bg-placeholder"></div>
{/if}
<div class={ARTWORK_SLOT_WRAPPER}>
<div class={ARTWORK_SLOT_FLOWER}>
<MuseumFrame
mode="bouquet"
imageSrc={imageSrc || null}
imageAlt="Generated bouquet"
{loading}
/>
</div>
<DescriptionCard {title} {description} />
<div class={ARTWORK_SLOT_CARD}>
<DescriptionCard {title} {description} />
</div>
</div>
</section>

View File

@@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
@@ -84,28 +85,45 @@
}
}
function hydrateOrderFromFlow() {
recipe = getFlowObject('recipe');
moodAnalysis = getFlowObject('moodAnalysis');
userInput = getFlowObject('userInput');
const order = buildFloristOrderMessage({
userInput: { ...sessionUserInput, ...(userInput ?? {}) },
moodAnalysis,
recipe
});
orderPlainText = order.plainText;
orderKoPlainText = order.ko.plainText;
}
onMount(async () => {
if (!jobId) {
await goto(resolve('/create'));
return;
}
if (!dev) {
await goto(resolve('/create'));
return;
}
hydrateOrderFromFlow();
} else {
try {
const job = await fetchJob(jobId);
recipe = job.recipe ?? null;
moodAnalysis = job.moodAnalysis ?? null;
userInput = job.userInput ?? null;
selectedImage = job.images?.primary ?? null;
try {
const job = await fetchJob(jobId);
recipe = job.recipe ?? null;
moodAnalysis = job.moodAnalysis ?? null;
userInput = job.userInput ?? null;
selectedImage = job.images?.primary ?? null;
const order = buildFloristOrderMessage({
userInput: { ...sessionUserInput, ...job.userInput },
moodAnalysis: job.moodAnalysis,
recipe: job.recipe
});
orderPlainText = order.plainText;
orderKoPlainText = order.ko.plainText;
} catch {
// job 없어도 지도·꽃집 검색은 계속
const order = buildFloristOrderMessage({
userInput: { ...sessionUserInput, ...job.userInput },
moodAnalysis: job.moodAnalysis,
recipe: job.recipe
});
orderPlainText = order.plainText;
orderKoPlainText = order.ko.plainText;
} catch {
if (dev) hydrateOrderFromFlow();
}
}
const center = await getUserMapCenter();

View File

@@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
@@ -13,7 +14,10 @@
buildBouquetRationale,
buildBriefBouquetTitle
} from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowString } from '$lib/flowerFlow/session.js';
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
/** dev에서 job fetch 없이 미리보기할 static 더미 이미지 */
const DEV_BOUQUET_PREVIEW = { url: '/dev/bouquet-m.svg' };
let loading = $state(true);
let error = $state('');
@@ -28,11 +32,25 @@
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
const bouquetFlowers = $derived(resolveRecipeFlowers(recipe, getFlowerImageSrc));
function hydrateResultFromFlow() {
recipe = getFlowObject('recipe');
moodAnalysis = getFlowObject('moodAnalysis');
userInput = getFlowObject('userInput');
selectedImage = DEV_BOUQUET_PREVIEW;
mock = true;
}
onMount(async () => {
const jobId = getFlowString('jobId');
if (!jobId) {
await goto(resolve('/create'));
if (!dev) {
await goto(resolve('/create'));
return;
}
hydrateResultFromFlow();
loading = false;
return;
}
@@ -45,6 +63,12 @@
mock = Boolean(job.mock);
loading = false;
} catch (err) {
if (dev) {
hydrateResultFromFlow();
loading = false;
return;
}
error = err instanceof Error ? err.message : 'Failed to load result';
loading = false;
}
@@ -79,6 +103,12 @@
{:else if error}
<p class="text-sm text-red-600">{error}</p>
{:else}
{#if dev && !recipe}
<p class="mb-4 text-sm text-muted">
Dev: 왼쪽 하단 <strong>Dev: → Result</strong>로 더미 job까지 한 번에 채울 수 있습니다.
</p>
{/if}
{#if mock}
<p class="mb-6 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
{/if}

View File

@@ -44,36 +44,22 @@
let submitting = $state(false);
let error = $state('');
const recipientLabel = $derived.by(() => {
const who = typeof userInput.relationship === 'string' ? userInput.relationship : '';
return who ? who.toLowerCase() : 'them';
});
const recipientPronoun = $derived.by(() => {
const style = typeof userInput.style === 'string' ? userInput.style.toLowerCase() : '';
if (style === 'masculine') return 'his';
if (style === 'feminine') return 'her';
return 'their';
});
const MOODBOARD_TILE_COPY = {
color: {
title: 'A hint of color',
description:
'The first thread pulled. Warm or cool, bold or shy. Their palette begins to speak.'
title: 'Their fashion',
description: 'One glimpse of their style. Add the other moodboard images when ready.'
},
season: {
title: 'Season in the air',
description: 'Spring lightness or winter hush. Time of year will breathe through the bouquet.'
title: 'Their season',
description: 'A season that fits them. Keep building the moodboard on the right.'
},
character: {
title: 'Their character',
description:
'A face, a gesture, a presence. Something in them is starting to take floral form.'
title: 'Their place',
description: 'A place that matches their vibe. A few more photos to go.'
},
location: {
title: 'A sense of place',
description: 'City grit or quiet coast. Where they belong roots the arrangement in memory.'
title: 'Their cafe',
description: 'A cafe that feels like them. Almost a full moodboard.'
}
};
@@ -89,7 +75,8 @@
return {
title: 'Their social world',
description: `Upload a screenshot of ${recipientPronoun} feed. One glance is often enough to sense the mood.`
description:
'Share a glimpse of their world! One glance is often enough to sense the mood.'
};
}
@@ -100,8 +87,9 @@
if (count === 0) {
return {
title: 'Gather their mood',
description: `Four small glimpses of color, season, character, and place. Together they become the palette for a bouquet made for ${recipientLabel}.`
title: 'Build their moodboard',
description:
'Upload fashion, season, place, and cafe photos on the right. Each one shapes their bouquet.'
};
}
@@ -111,23 +99,21 @@
if (count === 4) {
return {
title: 'A moodboard whole',
description:
'Color, season, character, and place. The collage is complete, and their bouquet is ready to take shape.'
title: 'Moodboard complete',
description: 'Four photos gathered. Their bouquet is ready to take shape.'
};
}
if (count === 2) {
return {
title: 'Taking shape',
description:
'The moodboard is finding its rhythm. Keep adding. Each image is another note in their story.'
description: 'Keep adding photos on the right. Each one sharpens the bouquet.'
};
}
return {
title: 'Almost there',
description: 'One last glimpse and their world will be fully gathered on the page.'
description: 'One more image and the moodboard is complete.'
};
});
@@ -239,7 +225,7 @@
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
<div
class="relative grid w-full max-w-[15rem] grid-cols-2 items-center rounded-full bg-white p-1 shadow-md ring-1 ring-black/5"
class="relative grid w-full max-w-[15rem] grid-cols-2 items-center rounded-full bg-white p-1 ring-1 ring-black/5"
role="tablist"
aria-label="Upload mode"
>