feat(app): atmosphere entry shell 고급화
This commit is contained in:
@@ -14,6 +14,10 @@ Last Updated: 2026-03-16
|
|||||||
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
|
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
|
||||||
- custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다
|
- custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다
|
||||||
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
||||||
|
- `/app` Atmosphere Entry Shell visual premium polish:
|
||||||
|
- no-session shell을 `decision rail + selected atmosphere stage + curated atmosphere library` 구조로 다시 짰다
|
||||||
|
- 좌측은 입력과 시작 결정을 담당하고, 우측은 선택한 atmosphere의 immersive preview를 크게 보여준다
|
||||||
|
- review entry는 start stage 아래 quiet dock로 내려 main CTA와 경쟁하지 않게 정리했다
|
||||||
|
|
||||||
- current session direct start 차단:
|
- current session direct start 차단:
|
||||||
- silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다
|
- silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Last Updated: 2026-03-16
|
|||||||
현재 상태:
|
현재 상태:
|
||||||
|
|
||||||
- `Slice 1` no-session shell 구현 완료
|
- `Slice 1` no-session shell 구현 완료
|
||||||
|
- `Slice 1-2` visual premium polish 구현 완료
|
||||||
- `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음
|
- `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음
|
||||||
|
|
||||||
핵심 정책 변경:
|
핵심 정책 변경:
|
||||||
@@ -151,6 +152,8 @@ Last Updated: 2026-03-16
|
|||||||
- goal input
|
- goal input
|
||||||
- duration input
|
- duration input
|
||||||
- start CTA
|
- start CTA
|
||||||
|
- editorial copy block
|
||||||
|
- selected atmosphere large preview stage
|
||||||
|
|
||||||
### Layer 3. Atmosphere Grid
|
### Layer 3. Atmosphere Grid
|
||||||
|
|
||||||
@@ -161,6 +164,21 @@ Last Updated: 2026-03-16
|
|||||||
핵심은
|
핵심은
|
||||||
**review / archive / history가 main stage를 먹지 않고, atmosphere grid도 secondary decoration이 아니라 실제 선택 surface로 작동해야 한다**는 점이다.
|
**review / archive / history가 main stage를 먹지 않고, atmosphere grid도 secondary decoration이 아니라 실제 선택 surface로 작동해야 한다**는 점이다.
|
||||||
|
|
||||||
|
### 현재 visual shell 원칙
|
||||||
|
|
||||||
|
- left: decision rail
|
||||||
|
- 큰 제목
|
||||||
|
- goal / duration input
|
||||||
|
- selected atmosphere summary
|
||||||
|
- primary CTA
|
||||||
|
- quiet review dock
|
||||||
|
- right: selected atmosphere stage
|
||||||
|
- immersive preview
|
||||||
|
- sound / scene / best-for meta
|
||||||
|
- bottom: curated atmosphere library
|
||||||
|
- 4열 grid
|
||||||
|
- selected state가 즉시 읽혀야 함
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 화면 구성
|
## 6. 화면 구성
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ Last Updated: 2026-03-16
|
|||||||
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
||||||
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다.
|
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다.
|
||||||
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
||||||
|
- `/app` Atmosphere Entry Shell visual premium polish를 반영했다.
|
||||||
|
- utility card 묶음 대신 `decision rail + selected atmosphere stage + curated library` 구조로 재구성했다.
|
||||||
|
- 좌측은 입력과 결심, 우측은 immersive preview, 하단은 12개 atmosphere library로 위계를 고정했다.
|
||||||
|
- review entry는 start stage 아래의 quiet dock로 낮춰 main CTA와 경쟁하지 않게 정리했다.
|
||||||
- `/app`은 이제 session gate를 보여주지 않는다.
|
- `/app`은 이제 session gate를 보여주지 않는다.
|
||||||
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다.
|
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다.
|
||||||
- 따라서 paused resume gate와 takeover sheet는 current UX가 아니다.
|
- 따라서 paused resume gate와 takeover sheet는 current UX가 아니다.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
||||||
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- 구현 완료, browser QA 대기
|
- 구현 완료, visual premium polish 반영, browser QA 대기
|
||||||
- 검증:
|
- 검증:
|
||||||
- `/app` no-session browser QA
|
- `/app` no-session browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { getSceneCardPhotoUrl } from '@/entities/scene';
|
|||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
import type { AtmosphereOption } from '../model/atmosphereEntry';
|
import type { AtmosphereOption } from '../model/atmosphereEntry';
|
||||||
|
|
||||||
const shellCardClass =
|
const stageShellClass =
|
||||||
'rounded-[2rem] border border-white/12 bg-[#0f1115]/26 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl';
|
'relative overflow-hidden rounded-[2.35rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.54)_0%,rgba(9,13,20,0.2)_52%,rgba(9,13,20,0.48)_100%)] shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[26px]';
|
||||||
const inputShellClass =
|
const fieldShellClass =
|
||||||
'w-full rounded-[1.5rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
'w-full rounded-[1.45rem] border border-white/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.05)_100%)] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.1]';
|
||||||
|
const reviewDockClass =
|
||||||
|
'group relative overflow-hidden rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl transition hover:border-white/18 hover:bg-white/[0.1]';
|
||||||
const primaryButtonClass =
|
const primaryButtonClass =
|
||||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
'inline-flex min-h-[3.65rem] items-center justify-center rounded-full border border-white/14 bg-white/[0.16] px-6 text-[15px] font-medium tracking-[-0.01em] text-white shadow-[0_10px_22px_rgba(5,10,20,0.24)] transition hover:bg-white/[0.2] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
interface AppAtmosphereEntryShellProps {
|
interface AppAtmosphereEntryShellProps {
|
||||||
canStart: boolean;
|
canStart: boolean;
|
||||||
@@ -64,22 +66,47 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
onStartSession,
|
onStartSession,
|
||||||
}: AppAtmosphereEntryShellProps) => {
|
}: AppAtmosphereEntryShellProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6 md:space-y-7">
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.18fr)_minmax(19rem,0.82fr)]">
|
<div className="grid gap-5 xl:grid-cols-[minmax(20rem,0.92fr)_minmax(0,1.08fr)]">
|
||||||
<section className={cn(shellCardClass, 'px-6 py-6 md:px-8 md:py-8')}>
|
<section className={cn(stageShellClass, 'px-6 py-6 md:px-8 md:py-8')}>
|
||||||
<div className="space-y-3">
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),rgba(255,255,255,0)_64%)]" />
|
||||||
<h1 className="text-[2rem] font-light leading-[1.04] tracking-[-0.045em] text-white md:text-[2.75rem]">
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-40 bg-[linear-gradient(270deg,rgba(255,255,255,0.06),rgba(255,255,255,0))]" />
|
||||||
이번엔 무엇을 붙잡을까요?
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="max-w-[30rem] space-y-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-white/46">
|
||||||
|
Focus Entry
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-[13ch] text-[2.45rem] font-light leading-[0.96] tracking-[-0.055em] text-white md:text-[3.65rem]">
|
||||||
|
이번 세션을
|
||||||
|
<br />
|
||||||
|
어떤 온도로 열까요?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[34rem] text-[15px] leading-[1.7] text-white/68 md:text-[15.5px]">
|
<p className="max-w-[30rem] text-[14px] leading-[1.72] text-white/64 md:text-[14.5px]">
|
||||||
목표는 한 줄이면 충분해요. 얼마나 붙잡을지와 어떤 분위기로 들어갈지만 정하면 바로
|
목표는 한 줄이면 충분합니다. 걸릴 시간을 적고, 오늘의 atmosphere 하나만 고르면
|
||||||
집중 화면으로 이어집니다.
|
바로 공간 안으로 들어갈 수 있어요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 space-y-5">
|
<div className="hidden min-w-[11.5rem] rounded-[1.4rem] border border-white/10 bg-white/[0.05] px-4 py-4 text-right xl:block">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/42">
|
||||||
|
선택한 Atmosphere
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[1rem] font-medium tracking-[-0.03em] text-white/90">
|
||||||
|
{selectedAtmosphere.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[12px] leading-[1.6] text-white/52">
|
||||||
|
{selectedAtmosphere.soundLabel}
|
||||||
|
<br />
|
||||||
|
{selectedAtmosphere.caption}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-5 border-t border-white/10 pt-6">
|
||||||
<label className="block space-y-2.5">
|
<label className="block space-y-2.5">
|
||||||
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
|
||||||
이번 목표
|
이번 목표
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -94,16 +121,17 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
}}
|
}}
|
||||||
placeholder={goalPlaceholder}
|
placeholder={goalPlaceholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputShellClass,
|
fieldShellClass,
|
||||||
'text-[1.12rem] font-light tracking-[-0.025em] placeholder:text-white/34 md:text-[1.34rem]',
|
'text-[1.16rem] font-light tracking-[-0.032em] placeholder:text-white/28 md:text-[1.42rem]',
|
||||||
)}
|
)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.88fr)_minmax(14rem,0.72fr)]">
|
||||||
|
<div className="space-y-2.5">
|
||||||
<label className="block space-y-2.5">
|
<label className="block space-y-2.5">
|
||||||
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
|
||||||
{durationInputLabel}
|
{durationInputLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -119,26 +147,32 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder={durationPlaceholder}
|
placeholder={durationPlaceholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputShellClass,
|
fieldShellClass,
|
||||||
'pr-12 text-[1.02rem] font-medium tracking-[-0.02em] placeholder:text-white/30',
|
'pr-14 text-[1.04rem] font-medium tracking-[-0.02em] placeholder:text-white/30',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-sm text-white/48">
|
<span className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-sm text-white/46">
|
||||||
분
|
분
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] leading-[1.6] text-white/48">{durationHelper}</p>
|
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-[12px] leading-[1.65] text-white/46">{durationHelper}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 md:max-w-[14.5rem] md:justify-end">
|
<div className="space-y-2.5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
|
||||||
|
빠른 선택
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{durationSuggestions.map((minutes) => (
|
{durationSuggestions.map((minutes) => (
|
||||||
<button
|
<button
|
||||||
key={minutes}
|
key={minutes}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectDuration(minutes)}
|
onClick={() => onSelectDuration(minutes)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full border border-white/12 bg-white/[0.05] px-3 py-1.5 text-[12px] font-medium text-white/76 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
|
'rounded-[1.1rem] border border-white/12 bg-white/[0.05] px-3 py-3 text-[12px] font-medium text-white/74 transition hover:border-white/18 hover:bg-white/[0.09] hover:text-white',
|
||||||
durationDraft === String(minutes) && 'border-white/28 bg-white/[0.13] text-white',
|
durationDraft === String(minutes) &&
|
||||||
|
'border-white/24 bg-white/[0.14] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{minutes}분
|
{minutes}분
|
||||||
@@ -146,17 +180,32 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-end sm:justify-between">
|
<div className="grid gap-4 border-t border-white/10 pt-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-3">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
|
<div className="rounded-[1.45rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.03)_100%)] px-4 py-4">
|
||||||
Selected Atmosphere
|
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/40">
|
||||||
|
현재 선택
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[14px] font-medium text-white/84">{selectedAtmosphere.name}</p>
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<p className="text-[12px] text-white/52">
|
<span className="text-[1rem] font-medium tracking-[-0.03em] text-white/90">
|
||||||
{selectedAtmosphere.soundLabel} · {selectedAtmosphere.caption}
|
{selectedAtmosphere.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/12 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/66">
|
||||||
|
{selectedAtmosphere.soundLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 max-w-[28rem] text-[12px] leading-[1.65] text-white/54">
|
||||||
|
{selectedAtmosphere.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sessionLookupError ? (
|
||||||
|
<p className="text-[13px] leading-[1.6] text-amber-100/84">{sessionLookupError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStartSession}
|
onClick={onStartSession}
|
||||||
@@ -167,62 +216,94 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionLookupError ? (
|
{reviewEntry ? (
|
||||||
<p className="text-sm text-amber-100/80">{sessionLookupError}</p>
|
<div className="border-t border-white/10 pt-5">
|
||||||
|
<div className={reviewDockClass}>{reviewEntry}</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="space-y-4">
|
<aside className={cn(stageShellClass, 'min-h-[29rem]')}>
|
||||||
<div className={cn(shellCardClass, 'overflow-hidden')}>
|
|
||||||
<div
|
<div
|
||||||
className="relative min-h-[18rem] bg-cover bg-center"
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(180deg, rgba(5,10,18,0.08) 0%, rgba(5,10,18,0.74) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
|
backgroundImage: `linear-gradient(180deg, rgba(7,10,16,0.08) 0%, rgba(7,10,16,0.46) 46%, rgba(7,10,16,0.88) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_42%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),rgba(255,255,255,0)_36%)]" />
|
||||||
<div className="absolute inset-x-0 bottom-0 p-5">
|
<div className="absolute inset-y-0 left-0 w-[52%] bg-[linear-gradient(90deg,rgba(5,8,14,0.56)_0%,rgba(5,8,14,0.22)_56%,rgba(5,8,14,0)_100%)]" />
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/52">
|
|
||||||
Selected Atmosphere
|
<div className="relative flex min-h-[29rem] h-full flex-col justify-between p-6 md:p-7">
|
||||||
</p>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h2 className="mt-2 text-[1.28rem] font-medium tracking-[-0.03em] text-white">
|
<div className="rounded-full border border-white/14 bg-white/[0.08] px-3 py-1.5 text-[11px] font-medium tracking-[0.14em] text-white/76">
|
||||||
{selectedAtmosphere.name}
|
Atmosphere Preview
|
||||||
</h2>
|
|
||||||
<p className="mt-2 max-w-[22rem] text-[13px] leading-[1.65] text-white/72">
|
|
||||||
{selectedAtmosphere.description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
|
|
||||||
{selectedAtmosphere.soundLabel}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
|
|
||||||
{selectedAtmosphere.scene.recommendedTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-full border border-white/14 bg-black/10 px-3 py-1.5 text-[11px] text-white/70 backdrop-blur-md">
|
||||||
|
{selectedAtmosphere.caption}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reviewEntry}
|
<div className="max-w-[28rem] space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||||
|
선택한 장면
|
||||||
|
</p>
|
||||||
|
<h2 className="text-[2rem] font-light leading-[0.95] tracking-[-0.05em] text-white md:text-[2.85rem]">
|
||||||
|
{selectedAtmosphere.name}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[26rem] text-[14px] leading-[1.72] text-white/72">
|
||||||
|
{selectedAtmosphere.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
||||||
|
Sound
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[13px] font-medium text-white/84">
|
||||||
|
{selectedAtmosphere.soundLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
||||||
|
Scene
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[13px] font-medium text-white/84">
|
||||||
|
{selectedAtmosphere.scene.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
||||||
|
Best For
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[13px] font-medium text-white/84">
|
||||||
|
{selectedAtmosphere.scene.recommendedTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div className="max-w-[44rem]">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
|
||||||
Atmosphere
|
Atmosphere Library
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-[1.2rem] font-medium tracking-[-0.03em] text-white md:text-[1.4rem]">
|
<h2 className="mt-3 text-[1.42rem] font-medium tracking-[-0.04em] text-white md:text-[1.9rem]">
|
||||||
{atmosphereTitle}
|
{atmosphereTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-[42rem] text-[13px] leading-[1.7] text-white/60 md:text-[13.5px]">
|
<p className="mt-3 text-[13px] leading-[1.7] text-white/58 md:text-[13.5px]">
|
||||||
{atmosphereBody}
|
{atmosphereBody}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-white/42 md:text-right">
|
<p className="text-[12px] text-white/40 lg:text-right">
|
||||||
총 {atmosphereOptions.length}개의 Atmosphere
|
총 {atmosphereOptions.length}개의 curated atmosphere
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,41 +317,49 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectAtmosphere(option.id)}
|
onClick={() => onSelectAtmosphere(option.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative min-h-[12.5rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out',
|
'group relative min-h-[14rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out',
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-white/28 shadow-[0_16px_36px_rgba(2,6,23,0.22)]'
|
? 'border-white/26 shadow-[0_18px_42px_rgba(2,6,23,0.24)]'
|
||||||
: 'border-white/10 hover:border-white/18',
|
: 'border-white/10 hover:border-white/18 hover:shadow-[0_14px_34px_rgba(2,6,23,0.18)]',
|
||||||
)}
|
)}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.04]"
|
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.035]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.88) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`,
|
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.42) 44%, rgba(7,10,14,0.9) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_44%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),rgba(255,255,255,0)_42%)]" />
|
||||||
<div className="relative flex h-full flex-col justify-between p-4">
|
<div className="relative flex h-full flex-col justify-between p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span className="rounded-full border border-white/14 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/72">
|
<span className="rounded-full border border-white/14 bg-white/[0.08] px-2.5 py-1 text-[10px] font-medium tracking-[0.14em] text-white/72">
|
||||||
{option.caption}
|
{option.caption}
|
||||||
</span>
|
</span>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<span className="rounded-full border border-white/18 bg-white/[0.14] px-2.5 py-1 text-[11px] font-medium text-white">
|
<span className="rounded-full border border-white/16 bg-white/[0.14] px-2.5 py-1 text-[10px] font-medium tracking-[0.14em] text-white">
|
||||||
선택됨
|
Current
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[1rem] font-medium tracking-[-0.025em] text-white">
|
<h3 className="text-[1.08rem] font-medium tracking-[-0.03em] text-white">
|
||||||
{option.name}
|
{option.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 line-clamp-2 text-[12px] leading-[1.55] text-white/66">
|
<p className="mt-2 line-clamp-3 text-[12px] leading-[1.62] text-white/66">
|
||||||
{option.description}
|
{option.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-white/56">
|
</div>
|
||||||
<span>{option.soundLabel}</span>
|
|
||||||
<span>{option.scene.recommendedTime}</span>
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/60">
|
||||||
|
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm">
|
||||||
|
{option.soundLabel}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm">
|
||||||
|
{option.scene.recommendedTime}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,9 +367,10 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-white/38">
|
|
||||||
고른 Atmosphere는 배경과 사운드가 함께 적용되고, 시간은 가장 가까운 기본 리듬으로
|
<p className="text-[12px] leading-[1.6] text-white/38">
|
||||||
먼저 맞춰집니다.
|
고른 atmosphere는 배경과 사운드가 함께 적용됩니다. 시간은 직접 적은 값을 우선
|
||||||
|
유지하고, 아직 손대지 않았다면 선택한 atmosphere의 기본 길이로 먼저 맞춰집니다.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const entryCopy = {
|
|||||||
startLoading: '입장 준비 중...',
|
startLoading: '입장 준비 중...',
|
||||||
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
||||||
atmosphereBody:
|
atmosphereBody:
|
||||||
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
|
'배경과 사운드는 하나의 atmosphere로 움직입니다. 지금 할 일의 온도에 맞는 분위기 하나만 고르면 바로 들어갈 수 있어요.',
|
||||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||||
reviewEyebrow: 'Weekly Review',
|
reviewEyebrow: 'Weekly Review',
|
||||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||||
@@ -74,7 +74,7 @@ const entryCopy = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goalCardClass =
|
const goalCardClass =
|
||||||
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
|
'w-full rounded-[2.2rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.52)_0%,rgba(9,13,20,0.24)_52%,rgba(9,13,20,0.5)_100%)] px-6 py-6 shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[24px] md:px-8 md:py-8';
|
||||||
|
|
||||||
const reviewCarryCopyByHint: Record<
|
const reviewCarryCopyByHint: Record<
|
||||||
ReviewCarryHint,
|
ReviewCarryHint,
|
||||||
@@ -311,18 +311,19 @@ export const FocusDashboardWidget = () => {
|
|||||||
)}
|
)}
|
||||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.28)_42%,rgba(2,6,23,0.54)_100%)]" />
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),rgba(255,255,255,0)_42%)]" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_left,rgba(6,10,20,0.24),rgba(6,10,20,0)_36%)]" />
|
||||||
|
|
||||||
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
||||||
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-white/56">
|
||||||
{entryCopy.eyebrow}
|
{entryCopy.eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<PlanPill plan={plan} onClick={openPaywall} />
|
<PlanPill plan={plan} onClick={openPaywall} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
|
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-start justify-center px-4 pb-10 pt-3 md:px-6 md:pb-12 md:pt-6">
|
||||||
<div className="w-full max-w-[42rem]">
|
<div className="w-full max-w-[86rem]">
|
||||||
{isCheckingSession ? (
|
{isCheckingSession ? (
|
||||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||||
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
||||||
@@ -330,11 +331,11 @@ export const FocusDashboardWidget = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{reviewReturnCopy ? (
|
{reviewReturnCopy ? (
|
||||||
<div className="rounded-[1.55rem] border border-white/10 bg-[#0f1115]/16 px-5 py-4 backdrop-blur-lg">
|
<div className="rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
{entryCopy.reviewReturnEyebrow}
|
{entryCopy.reviewReturnEyebrow}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/90">
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/90">
|
||||||
{reviewReturnCopy.title}
|
{reviewReturnCopy.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
||||||
@@ -361,14 +362,14 @@ export const FocusDashboardWidget = () => {
|
|||||||
shouldShowWeeklyReviewTeaser ? (
|
shouldShowWeeklyReviewTeaser ? (
|
||||||
<Link
|
<Link
|
||||||
href="/stats"
|
href="/stats"
|
||||||
className="block rounded-[1.6rem] border border-white/10 bg-[#0f1115]/18 px-5 py-4 backdrop-blur-lg transition hover:bg-[#0f1115]/24"
|
className="block transition"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
{entryCopy.reviewEyebrow}
|
{entryCopy.reviewEyebrow}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/88">
|
||||||
{reviewTeaserTitle}
|
{reviewTeaserTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[13px] leading-[1.6] text-white/62">
|
<p className="mt-2 text-[13px] leading-[1.6] text-white/62">
|
||||||
@@ -376,7 +377,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74 transition group-hover:text-white">
|
||||||
{reviewTeaserCta}
|
{reviewTeaserCta}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user