feat: redesign upload experience and visual branding

* feat: add animated flower step indicator

* feat: add custom pixel cursor

* feat: update branding, cursor, and artwork cards

* feat: add paper texture background

* style: update description card background

* feat: redesign upload moodboard collage layout

* style: update upload colors and add sliding mode toggle

* fix: add static favicon and apple touch icons
This commit is contained in:
Chaewon Lee
2026-06-13 01:20:54 +09:00
committed by GitHub
parent e7c690ac13
commit dda6ca972d
18 changed files with 549 additions and 109 deletions

View File

@@ -2,7 +2,7 @@
let { title = 'Title', description = 'Description Description Description' } = $props();
</script>
<div class="min-w-0 flex-1 border border-line-strong px-4 py-3 lg:w-54 lg:flex-none lg:px-6 lg:py-5">
<h3 class="text-sm">{title}</h3>
<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>
</div>

View File

@@ -0,0 +1,68 @@
<script>
import { onMount } from 'svelte';
import cursorUrl from '$lib/assets/cursor.svg';
let visible = $state(false);
let x = $state(0);
let y = $state(0);
onMount(() => {
const canUseCustomCursor = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (!canUseCustomCursor) return;
document.documentElement.classList.add('flower-cursor');
function handlePointerMove(event) {
x = event.clientX;
y = event.clientY;
visible = true;
}
function handlePointerLeave() {
visible = false;
}
window.addEventListener('pointermove', handlePointerMove);
document.addEventListener('mouseleave', handlePointerLeave);
return () => {
document.documentElement.classList.remove('flower-cursor');
window.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('mouseleave', handlePointerLeave);
};
});
</script>
{#if visible}
<div class="flower-cursor-layer" style={`transform: translate3d(${x}px, ${y}px, 0)`}>
<img class="flower-cursor-icon" src={cursorUrl} alt="" aria-hidden="true" />
</div>
{/if}
<style>
:global(html.flower-cursor),
:global(html.flower-cursor *) {
cursor: none !important;
}
.flower-cursor-layer {
position: fixed;
top: 0;
left: 0;
z-index: 2147483647;
pointer-events: none;
translate: -0.3rem -0.3rem;
}
.flower-cursor-icon {
display: block;
width: 2.35rem;
height: 2.35rem;
}
@media (prefers-reduced-motion: reduce) {
.flower-cursor-layer {
transition: none;
}
}
</style>

View File

@@ -1,4 +1,6 @@
<script>
import logoUrl from '$lib/assets/logo.svg';
// `step` is 1-based; the matching dot is highlighted as the current step.
let { step = 1, total = 7 } = $props();
@@ -7,15 +9,45 @@
<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">
<span class="size-4 shrink-0 bg-placeholder"></span>
<span class="text-lg tracking-wide">AI Florist</span>
<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>
<div class="flex items-center gap-3 sm:gap-4">
{#each dots as dot (dot)}
<span
class={['rounded-[1px]', dot === step - 1 ? 'size-2.5 bg-subtle' : 'size-2 bg-placeholder']}
></span>
<svg
class={[
'shrink-0',
dot === step - 1
? 'flower-icon--current size-4.5 text-subtle'
: 'size-3.5 text-placeholder'
]}
viewBox="0 0 64 64"
aria-hidden="true"
>
<path
fill="currentColor"
d="M32 6 C36 6 38.5 10.5 38 15.5 C42.5 12 48 12.5 50.5 16 C53 19.5 50.5 24.5 46 26.5 C51 27.5 54.5 31.5 53.5 36 C52.5 40.5 47 42 42.5 40 C45.5 44 45 49.5 41 52 C37 54.5 32.5 51.5 31 47 C29.5 51.5 25 54.5 21 52 C17 49.5 16.5 44 19.5 40 C15 42 9.5 40.5 8.5 36 C7.5 31.5 11 27.5 16 26.5 C11.5 24.5 9 19.5 11.5 16 C14 12.5 19.5 12 24 15.5 C23.5 10.5 28 6 32 6 Z"
/>
</svg>
{/each}
</div>
</header>
<style>
.flower-icon--current {
animation: flower-spin 2.4s linear infinite;
}
@keyframes flower-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.flower-icon--current {
animation: none;
}
}
</style>

View File

@@ -4,7 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null) } = $props();
let { primaryFile = $bindable(null), caption = 'build their moodboard!' } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
@@ -12,7 +12,8 @@
let locationFile = $state(null);
$effect(() => {
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
const next = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
if (primaryFile !== next) primaryFile = next;
});
onMount(async () => {
@@ -35,65 +36,170 @@
</script>
<div class="moodboard min-h-0 w-full flex-1">
<UploadTile
label="Color"
bind:file={colorFile}
class="tile tile-color aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Season"
bind:file={seasonFile}
class="tile tile-season aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Character"
bind:file={characterFile}
class="tile tile-character aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Location"
bind:file={locationFile}
class="tile tile-location aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<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>
</div>
<style>
.moodboard {
display: grid;
gap: 0;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
@media (min-width: 1024px) {
.collage {
position: relative;
width: min(100%, 34rem);
height: 100%;
aspect-ratio: 4 / 5.2;
max-height: 44rem;
}
.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 {
grid-template-rows: repeat(5, 1fr);
grid-template-areas:
'color season'
'color season'
'color location'
'character location'
'character location';
min-height: 34rem;
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
:global(.tile-color) {
grid-area: color;
.collage {
display: grid;
width: 100%;
aspect-ratio: auto;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
:global(.tile-season) {
grid-area: season;
.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) {
grid-area: character;
aspect-ratio: 4 / 3;
}
:global(.tile-location) {
grid-area: location;
.mood-number,
.mood-caption {
display: none;
}
}
</style>

View File

@@ -4,13 +4,13 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null) } = $props();
let { primaryFile = $bindable(null), caption = 'upload their feed!' } = $props();
let firstFile = $state(null);
let secondFile = $state(null);
$effect(() => {
primaryFile = firstFile ?? secondFile ?? null;
const next = firstFile ?? null;
if (primaryFile !== next) primaryFile = next;
});
onMount(async () => {
@@ -23,7 +23,6 @@
try {
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
if (files.first) firstFile = files.first;
if (files.second) secondFile = files.second;
} catch {
// dev seed 실패 시 빈 타일 유지
}
@@ -31,45 +30,89 @@
</script>
<div class="feed min-h-0 w-full flex-1">
<UploadTile
bind:file={firstFile}
class="tile-one aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
bind:file={secondFile}
class="tile-two aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<div class="sns-collage">
<span class="sns-number">(01)</span>
<span class="sns-caption">{caption}</span>
<UploadTile bind:file={firstFile} class="sns-tile" />
</div>
</div>
<style>
.feed {
display: grid;
gap: 0;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem 1.5rem 1rem;
}
@media (min-width: 1024px) {
.sns-collage {
position: relative;
width: min(100%, 42rem);
height: 100%;
aspect-ratio: 4 / 5;
max-height: 34rem;
}
.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 {
grid-template-rows: repeat(5, 1fr);
grid-template-areas:
'. two'
'one two'
'one two'
'one two'
'one .';
min-height: 34rem;
align-items: flex-start;
padding: 1.5rem 1rem 7rem;
}
:global(.tile-one) {
grid-area: one;
.sns-collage {
width: 100%;
aspect-ratio: auto;
min-height: 0;
}
:global(.tile-two) {
grid-area: two;
.feed :global(.sns-tile) {
position: relative;
inset: auto;
width: 100%;
height: auto;
aspect-ratio: 4 / 5;
transform: none;
}
.sns-number,
.sns-caption {
display: none;
}
}
</style>

View File

@@ -3,7 +3,13 @@
// the chosen image (cover) when filled. Layout (size / grid placement) is
// supplied by the parent via `class` and `style` so the same tile works in
// both the moodboard and the SNS feed.
let { label = null, class: klass = '', style = '', file = $bindable(null) } = $props();
let {
label = null,
showLabel = true,
class: klass = '',
style = '',
file = $bindable(null)
} = $props();
let preview = $state(null);
@@ -44,14 +50,14 @@
{#if preview}
<img src={preview} alt={label ?? ''} class="h-full w-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/45 to-transparent"></div>
{#if label}
<div class="absolute inset-0 bg-linear-to-t from-ink/45 to-transparent"></div>
{#if label && showLabel}
<span class="absolute bottom-3 left-4 text-sm tracking-[0.15em] text-surface uppercase"
>{label}</span
>
{/if}
<span
class="absolute top-3 right-3 rounded-full bg-black/40 px-2.5 py-1 text-xs text-surface opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
class="absolute top-3 right-3 rounded-full bg-ink/40 px-2.5 py-1 text-xs text-surface opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
>
Change
</span>
@@ -63,7 +69,7 @@
class="flex size-10 items-center justify-center rounded-full border border-current text-xl leading-none"
aria-hidden="true">+</span
>
{#if label}
{#if label && showLabel}
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
{/if}
</div>