diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ff7cdfe
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+OPENAI_API_KEY=
+OPENAI_IMAGE_MODEL=gpt-image-1.5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dd7da2e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ lint-and-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Build
+ run: npm run build
diff --git a/.gitignore b/.gitignore
index b4a2eef..9d69e5f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..2bd5a0a
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..061f635
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,46 @@
+# Contributing to openlayout
+
+Thanks for taking the time to improve openlayout.
+
+## Development setup
+
+```bash
+npm install
+npm run dev
+```
+
+Open `http://localhost:3000/web-layouts`.
+
+## Before sending changes
+
+Run both checks:
+
+```bash
+npm run lint
+npm run build
+```
+
+## Adding a web layout
+
+Layout data lives in `src/data/webLayouts.ts`.
+
+1. Add a seed to `layoutSeeds`.
+2. Reuse an existing `category` and `previewType` when possible.
+3. Add a category or preview type only when the current set cannot describe the structure.
+4. Update `LayoutPreviewRenderer.tsx` only when a new preview type needs a renderer.
+
+## Adding a design style
+
+Design style data lives in `src/data/designStyles.ts`.
+
+1. Add an entry to `styleSeedTuples`.
+2. Add a palette when the generated fallback palette is not specific enough.
+3. Reuse an existing `sampleType` when possible.
+4. Update `DesignStyleSampleRenderer.tsx` only when a new sample type is needed.
+
+## Pull request expectations
+
+- Keep changes focused.
+- Include screenshots or short notes for visual changes.
+- Avoid adding external services unless the feature cannot work without them.
+- Do not commit generated images unless they are intentionally part of the project.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4c074ba
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 pandaofwild
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 67ca5e0..a6d9f10 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# openlayout
-웹사이트 레이아웃을 찾고, 비교하고, 실제 와이어프레임으로 확인하는 Web Layout Library입니다.
+웹사이트 레이아웃과 디자인 형식을 분리해서 고르고, 비교하고, 실제 웹페이지형 프리뷰로 확인하는 Web Layout Library입니다.
-openlayout은 디자인을 시작하기 전에 페이지 구조를 빠르게 고를 수 있도록 만든 레이아웃 사전입니다. 96개의 레이아웃, 12개의 카테고리, 16개의 프리뷰 템플릿을 제공하며, 각 레이아웃은 추천 용도, 장단점, 반응형 동작, 접근성 체크포인트, Tailwind 구현 힌트를 함께 보여줍니다.
+openlayout은 디자인을 시작하기 전에 페이지 구조와 시각 언어를 빠르게 고를 수 있도록 만든 레이아웃/스타일 사전입니다. 96개의 레이아웃, 88개의 디자인 형식, 10개의 스타일 카테고리, 웹페이지형 샘플 렌더러를 제공하며, 각 항목은 추천 용도, 장단점, 반응형 동작, 접근성 체크포인트, 색상표, Tailwind 구현 힌트를 함께 보여줍니다.
| 항목 | 값 |
| --- | --- |
| Repository | https://github.com/pandaofwild/openlayout |
-| 마지막 검토일 | 2026-06-01 |
+| 마지막 검토일 | 2026-06-03 |
## 대상 사용자
@@ -21,9 +21,13 @@ openlayout은 디자인을 시작하기 전에 페이지 구조를 빠르게 고
- **Full-stage preview**: 상세 페이지와 비교 페이지에서 레이아웃을 실제 웹페이지 배경처럼 크게 확인합니다.
- **Floating detail panel**: 기본 화면에는 작은 요약 박스만 두고, 클릭하면 구조 설명과 장단점이 패널로 떠오릅니다.
- **Compare view**: 최대 3개의 레이아웃을 선택해 추천 용도, 모바일 대응, 밀도, 난이도를 나란히 비교합니다.
+- **Design Style Library**: 88개의 디자인 형식을 카테고리, 태그, 검색어로 탐색하고 상세 페이지에서 색상표와 웹페이지형 샘플을 확인합니다.
+- **Style application**: `/design-styles`에서 선택한 디자인 형식이 `/web-layouts`와 `/web-layouts/compare`의 프리뷰 톤에 적용되고 localStorage에 유지됩니다.
+- **Prompt palette**: 프롬프트를 섞어 커스텀 색상표를 생성하고 현재 레이아웃 프리뷰에 바로 적용합니다.
+- **Image generation admin**: `OPENAI_API_KEY`가 있는 로컬 환경에서 스타일별 참조 이미지를 생성해 `public/generated/design-styles`에 저장할 수 있습니다.
- **SVG controls**: 비교 화살표와 설명/닫기/상세 아이콘은 inline SVG로 관리합니다.
- **Implementation hints**: previewType별 Tailwind 코드 예시와 구현 팁을 제공합니다.
-- **Project skill**: `skills/layout-recommender/SKILL.md`가 목적별 레이아웃 추천 방식을 안내합니다.
+- **Project skills**: `skills/layout-recommender/SKILL.md`와 `skills/design-style-recommender/SKILL.md`가 목적별 레이아웃/스타일 추천 방식을 안내합니다.
## 바이브 코딩에 도움되는 점
@@ -31,13 +35,14 @@ openlayout은 디자인을 시작하기 전에 페이지 구조를 빠르게 고
- 각 레이아웃은 추천 용도, 피해야 할 상황, 반응형 동작, 접근성 체크포인트를 함께 갖고 있어 프롬프트에 넣을 설계 조건을 바로 뽑아낼 수 있습니다.
- 비교 페이지의 큰 프리뷰와 플로팅 설명 패널을 보면서 "이 구조로 가자", "이건 모바일에서 약하다" 같은 결정을 짧은 피드백으로 반복하기 좋습니다.
- `previewType`은 구현 방향의 압축어처럼 쓸 수 있습니다. 예: `hero`, `card-grid`, `dashboard`, `docs`, `comparison`.
-- 새 화면을 만들 때는 먼저 이 레이아웃 사전에서 구조를 고르고, 그 다음 색상, 카피, 컴포넌트 디테일을 코딩 에이전트에게 맡기는 흐름이 가장 안정적입니다.
+- `DesignStyle`은 시각 방향의 압축어처럼 쓸 수 있습니다. 예: `brutalism`, `cyberpunk`, `luxury`, `organic-design`, `saas-style`.
+- 새 화면을 만들 때는 먼저 레이아웃 사전에서 구조를 고르고, 그 다음 디자인 형식에서 색상/타이포/분위기를 고른 뒤, 컴포넌트와 카피를 코딩 에이전트에게 맡기는 흐름이 가장 안정적입니다.
## 빠른 시작
요구 사항:
-- Node.js
+- Node.js 22 이상
- npm
의존성 설치:
@@ -67,6 +72,32 @@ http://localhost:3000/web-layouts
| `/web-layouts` | 레이아웃 검색, 필터, 카드 목록 |
| `/web-layouts/[slug]` | 구조 설명, 장단점, 반응형 동작, 접근성 노트, 라이브 프리뷰, 코드 예시 |
| `/web-layouts/compare` | 최대 3개 레이아웃 비교와 큰 구조 미리보기 |
+| `/design-styles` | 디자인 형식 검색, 카테고리/태그 필터, 색상표, 웹페이지형 스타일 샘플 |
+| `/design-styles/[slug]` | 디자인 형식 상세 설명, 색상표, 타이포/레이아웃 특징, 관련 스타일 |
+| `/design-styles/generate` | OpenAI Image API 기반 로컬 참조 이미지 생성 관리자 |
+
+## 이미지 생성 환경 변수
+
+`/design-styles/generate`와 `/api/design-style-images`는 로컬 관리자 기능입니다.
+
+```bash
+OPENAI_API_KEY=sk-...
+OPENAI_IMAGE_MODEL=gpt-image-1.5
+```
+
+- `OPENAI_API_KEY`는 필수입니다.
+- `OPENAI_IMAGE_MODEL`은 선택값이며 기본값은 `gpt-image-1.5`입니다.
+- 생성 결과는 `public/generated/design-styles/{slug}.webp`에 저장됩니다.
+- Vercel 같은 읽기 전용 배포 환경에서는 Blob/S3 같은 외부 저장소로 바꾸는 것이 안전합니다.
+
+## 오픈소스 사용
+
+이 프로젝트는 MIT 라이선스로 배포됩니다. 자세한 조건은 `LICENSE`를 확인하세요.
+
+- 기여 방법: `CONTRIBUTING.md`
+- 보안 제보: `SECURITY.md`
+- 로컬 환경 변수 예시: `.env.example`
+- CI: `.github/workflows/ci.yml`
## 품질 확인
@@ -92,10 +123,18 @@ npm run build
src/app/web-layouts/page.tsx # Explorer page
src/app/web-layouts/[slug]/page.tsx # Layout detail page
src/app/web-layouts/compare/page.tsx # Compare page shell
+src/app/design-styles/page.tsx # Design style library page
+src/app/design-styles/[slug]/page.tsx # Design style detail page
+src/app/design-styles/generate/page.tsx # Local image generation admin
+src/app/api/design-style-images/route.ts # OpenAI Image API route
src/data/webLayouts.ts # Layout catalog and generated metadata
+src/data/designStyles.ts # 88 design styles and generated metadata
src/components/web-layout/ # Explorer, cards, previews, compare UI
+src/components/design-style/ # Style cards, filters, samples, generator UI
+src/components/style-preset/ # Global selected style provider
src/components/ui/ # Small shared UI primitives
skills/layout-recommender/SKILL.md # Purpose-based layout recommendation skill
+skills/design-style-recommender/SKILL.md # Brand-tone-based style recommendation skill
```
주요 컴포넌트:
@@ -111,11 +150,18 @@ skills/layout-recommender/SKILL.md # Purpose-based layout recommendation
| `WireframeThumbnail.tsx` | 카드와 비교 화면에 쓰이는 구조 썸네일 |
| `LayoutCodeExample.tsx` | 복사 가능한 Tailwind 구현 예시 |
| `WebLayoutCompare.tsx` | 비교 페이지 선택, SVG 화살표 탐색, 큰 프리뷰 표시 |
+| `DesignStyleLibrary.tsx` | 디자인 형식 목록의 검색, 필터, 적용 상태 |
+| `DesignStyleCard.tsx` | 디자인 형식 카드, 색상표, 웹페이지형 샘플, 적용 버튼 |
+| `DesignStyleSampleRenderer.tsx` | sampleType별 10개 웹페이지형 스타일 샘플 |
+| `StylePresetProvider.tsx` | 선택된 디자인 형식과 커스텀 팔레트 localStorage 유지 |
+| `DesignStyleImageGenerator.tsx` | 스타일 참조 이미지 생성 관리자 UI |
## 프로젝트 스킬
`skills/layout-recommender/SKILL.md`는 코딩 에이전트가 사용 목적에 맞는 레이아웃을 추천할 때 읽는 내부 스킬입니다.
+`skills/design-style-recommender/SKILL.md`는 브랜드 톤, 업종, 감정, 타이포그래피, 색상 방향에 맞는 디자인 형식을 추천할 때 읽는 내부 스킬입니다.
+
추천 요청 예시:
```text
@@ -124,6 +170,14 @@ skills/layout-recommender/SKILL.md # Purpose-based layout recommendation
스킬은 `src/data/webLayouts.ts`의 카테고리, `bestFor`, `notGoodFor`, `tags`, `previewType`을 먼저 확인하고 1순위 후보, 대안, 피해야 할 구조를 짧게 제안하도록 설계되어 있습니다.
+디자인 스타일 추천 요청 예시:
+
+```text
+프리미엄 뷰티 브랜드 랜딩인데 너무 화려하진 않고 고급스럽게 보여야 해. 어떤 디자인 형식이 좋아?
+```
+
+디자인 스타일 스킬은 `src/data/designStyles.ts`의 `category`, `tags`, `goodFor`, `useCases`, `palette`, `sampleType`을 기준으로 1순위 스타일, 대안, 피해야 할 스타일을 제안합니다.
+
## 레이아웃 추가
레이아웃 데이터는 `src/data/webLayouts.ts`에서 관리합니다.
@@ -135,6 +189,16 @@ skills/layout-recommender/SKILL.md # Purpose-based layout recommendation
새 카테고리를 추가할 때는 `categoryGuides`에도 설명과 기본값을 추가해야 합니다.
+## 디자인 형식 추가
+
+디자인 형식 데이터는 `src/data/designStyles.ts`에서 관리합니다.
+
+1. `styleSeedTuples`에 새 항목을 추가합니다.
+2. `slug`, `nameKo`, `nameEn`, `category`, `tone`, `tags`, `sampleType`을 입력합니다.
+3. 필요하면 `palettes`에 해당 slug의 9색 팔레트를 추가합니다.
+4. 새 범주가 필요하면 `categoryProfiles`에 시각 특징, 색상 노트, 타이포그래피, 레이아웃 경향을 추가합니다.
+5. 기존 10개 샘플 렌더러로 표현이 부족할 때만 `DesignStyleSampleType`과 `DesignStyleSampleRenderer.tsx`를 확장합니다.
+
## Preview Type 추가
기존 템플릿으로 구조가 충분히 드러나지 않을 때 새 previewType을 추가합니다.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..54e621f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,19 @@
+# Security Policy
+
+## Supported versions
+
+The latest code on the default development branch is supported.
+
+## Reporting a vulnerability
+
+Please report security issues through GitHub security advisories if available, or open an issue with a minimal description that does not expose exploit details publicly.
+
+Include:
+
+- The affected route, component, or script.
+- Steps to reproduce.
+- Impact and any known workaround.
+
+## Secrets and local image generation
+
+The image generation route uses `OPENAI_API_KEY` only for local administrator workflows. Do not commit real API keys. Use `.env.local` for local secrets and `.env.example` for documented placeholders.
diff --git a/docs/superpowers/plans/2026-06-02-design-style-library.md b/docs/superpowers/plans/2026-06-02-design-style-library.md
new file mode 100644
index 0000000..c667304
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-02-design-style-library.md
@@ -0,0 +1,1236 @@
+# Design Style Library Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a separate Design Style Library where every style from the pasted prompt has its own searchable card, detail page, palette, usage guidance, image prompt, and actual webpage-like sample.
+
+**Architecture:** Keep `/web-layouts` focused on layout structure and make `/design-styles` the source of truth for visual formats. Design styles are data-driven TypeScript records, rendered by reusable category, card, detail, and sample components. The selected design style is persisted through the existing client provider and applied to layout previews through CSS variables.
+
+**Tech Stack:** Next.js App Router, TypeScript, Tailwind CSS v4, local TypeScript data, client-side search/filter state, server-rendered detail pages, optional OpenAI image generation API in a later milestone.
+
+---
+
+## Current Baseline
+
+- [x] Repository root: `F:\coding\design_pan`
+- [x] Existing layout library: `src/app/web-layouts/page.tsx`
+- [x] Existing layout detail pages: `src/app/web-layouts/[slug]/page.tsx`
+- [x] Existing layout preview renderer: `src/components/web-layout/LayoutPreviewRenderer.tsx`
+- [x] Temporary design-style prototype exists in the working tree:
+ - `src/app/design-styles/page.tsx`
+ - `src/components/style-preset/StylePresetProvider.tsx`
+ - `src/components/style-preset/StylePresetPanel.tsx`
+ - `src/components/style-preset/AppliedStyleStrip.tsx`
+ - `src/data/stylePresets.ts`
+ - `src/lib/paletteGenerator.ts`
+- [ ] Convert the temporary prototype into a full 88-style design library.
+- [ ] Commit only after each milestone passes `npm run lint` and `npm run build`.
+
+## Product Shape
+
+### User Flow
+
+1. User opens `/design-styles`.
+2. User searches or filters design styles by category/tag.
+3. User opens `/design-styles/[slug]`.
+4. User sees a real webpage-like style sample, palette, typography notes, layout traits, use cases, cautions, related styles, and image prompt.
+5. User selects a design style.
+6. User opens `/web-layouts` or `/web-layouts/compare`.
+7. Layout examples are rendered with the selected style.
+
+### Route Map
+
+- `/design-styles`
+ - Full design style library with search, filters, style cards, palette swatches, and sample thumbnails.
+- `/design-styles/[slug]`
+ - Detail page for one style with full explanation and real sample page.
+- `/web-layouts`
+ - Layout structure library. Shows a compact "Applied design format" strip and a link back to `/design-styles`.
+- `/web-layouts/compare`
+ - Existing compare flow, using the selected style variables.
+- `/admin/generate-design-images`
+ - Later milestone. Local development admin page for image generation.
+- `/api/generate-design-image`
+ - Later milestone. Server-only OpenAI image generation route.
+
+---
+
+## Data Model
+
+### Final Type
+
+Create `src/data/designStyles.ts` and use this type:
+
+```ts
+export type DesignStylePalette = {
+ base: string;
+ surface: string;
+ text: string;
+ mutedText: string;
+ primary: string;
+ accent: string;
+ accent2: string;
+ accent3: string;
+ border: string;
+};
+
+export type DesignStyleSampleType =
+ | "minimal-editorial"
+ | "brutalist-poster"
+ | "retro-commerce"
+ | "cyber-dashboard"
+ | "luxury-product"
+ | "organic-brand"
+ | "kawaii-app"
+ | "street-campaign"
+ | "magazine-layout"
+ | "saas-landing";
+
+export type DesignStyle = {
+ slug: string;
+ nameKo: string;
+ nameEn: string;
+ category: string;
+ summary: string;
+ description: string;
+ visualFeatures: string[];
+ colorPalette: string[];
+ typography: string[];
+ layoutTraits: string[];
+ useCases: string[];
+ goodFor: string[];
+ cautions: string[];
+ tags: string[];
+ related: string[];
+ palette: DesignStylePalette;
+ imagePrompt: string;
+ sampleType: DesignStyleSampleType;
+};
+```
+
+### Required 88 Styles
+
+#### 모던 / 미니멀
+
+- [x] 미니멀리즘
+- [x] 모더니즘
+- [x] 스위스 디자인
+- [x] 인터내셔널 스타일
+- [x] 스칸디나비안
+- [x] 재팬디
+- [x] 웜 미니멀
+- [x] 소프트 미니멀
+- [x] 하이엔드 미니멀
+
+#### 강렬 / 실험
+
+- [x] 브루탈리즘
+- [x] 뉴 브루탈리즘
+- [x] 안티디자인
+- [x] 맥시멀리즘
+- [x] 글리치 아트
+- [x] 디컨스트럭티비즘
+- [x] 아방가르드
+- [x] 포스트모더니즘
+
+#### 레트로 / 빈티지
+
+- [x] 레트로
+- [x] 빈티지
+- [x] 70년대 레트로
+- [x] 80년대 레트로
+- [x] 90년대 그래픽
+- [x] Y2K
+- [x] 레트로 퓨처리즘
+- [x] 미드센추리 모던
+- [x] 바우하우스
+
+#### 미래 / 디지털
+
+- [x] 퓨처리즘
+- [x] 사이버펑크
+- [x] 네온 누아르
+- [x] 테크웨어
+- [x] 하이테크
+- [x] AI 에스테틱
+- [x] 홀로그램 스타일
+- [x] 크롬코어
+- [x] 메타버스 스타일
+
+#### 럭셔리 / 클래식
+
+- [x] 클래식
+- [x] 네오클래식
+- [x] 럭셔리
+- [x] 올드머니
+- [x] 아르데코
+- [x] 아르누보
+- [x] 바로크
+- [x] 로코코
+- [x] 고딕
+
+#### 자연 / 수공예
+
+- [x] 오가닉 디자인
+- [x] 내추럴
+- [x] 보태니컬
+- [x] 에코 디자인
+- [x] 러스틱
+- [x] 킨포크
+- [x] 핸드메이드
+- [x] 크래프트
+- [x] 와비사비
+
+#### 귀여움 / 캐주얼
+
+- [x] 키치
+- [x] 카와이
+- [x] 도파민 디자인
+- [x] 팝아트
+- [x] 코믹북 스타일
+- [x] 토이 디자인
+- [x] 플레이풀 디자인
+- [x] 파스텔 스타일
+- [x] 버블 디자인
+
+#### 스트리트 / 서브컬처
+
+- [x] 스트리트웨어
+- [x] 그래피티
+- [x] 힙합 스타일
+- [x] 스케이트 문화
+- [x] 펑크
+- [x] 그런지
+- [x] 인디 슬리즈
+- [x] 레이브 스타일
+- [x] 로우파이
+
+#### 편집 / 타이포그래피
+
+- [x] 타이포그래피 중심
+- [x] 에디토리얼 디자인
+- [x] 매거진 스타일
+- [x] 포스터리즘
+- [x] 그리드 시스템
+- [x] 콜라주
+- [x] 포토몽타주
+- [x] 실험 타이포
+- [x] 신문 스타일
+
+#### UI / 웹
+
+- [x] 플랫 디자인
+- [x] 머티리얼 디자인
+- [x] 뉴모피즘
+- [x] 글래스모피즘
+- [x] 클레이모피즘
+- [x] 다크모드 디자인
+- [x] SaaS 스타일
+- [x] 스타트업 랜딩페이지
+
+---
+
+## File Structure
+
+### Data
+
+- Create: `src/data/designStyles.ts`
+ - Full `DesignStyle` type.
+ - Full 88-style `designStyles` array.
+ - `designStyleCategories`, `designStyleTags`, `getDesignStyleBySlug`, `getRelatedDesignStyles`.
+- Retire: `src/data/stylePresets.ts`
+ - Replace imports with `src/data/designStyles.ts`.
+ - Delete after all imports are migrated.
+
+### State And Palette
+
+- Modify: `src/components/style-preset/StylePresetProvider.tsx`
+ - Rename context concepts from `StylePreset` to `DesignStyle`.
+ - Persist selected design style slug and custom prompt palette.
+ - Continue writing CSS variables on `.style-preset-root`.
+- Modify: `src/lib/paletteGenerator.ts`
+ - Rename imports to use `DesignStyle`.
+ - Keep deterministic local prompt palette generation.
+
+### Design Style Pages
+
+- Modify: `src/app/design-styles/page.tsx`
+ - Use `DesignStyleLibrary` client component.
+- Create: `src/app/design-styles/[slug]/page.tsx`
+ - Server-render detail page.
+ - Implement `generateStaticParams`.
+ - Implement `generateMetadata`.
+- Create: `src/components/design-style/DesignStyleLibrary.tsx`
+ - Client search/filter/list state.
+- Create: `src/components/design-style/DesignStyleFilters.tsx`
+ - Search, category, tag filters.
+- Create: `src/components/design-style/DesignStyleCard.tsx`
+ - Card view for each style.
+- Create: `src/components/design-style/DesignStyleSampleRenderer.tsx`
+ - Render actual webpage-like style sample based on `sampleType`.
+- Create: `src/components/design-style/DesignStyleDetailSection.tsx`
+ - Reusable detail sections.
+- Create: `src/components/design-style/ColorPaletteGrid.tsx`
+ - Palette swatches.
+- Create: `src/components/design-style/RelatedDesignStyles.tsx`
+ - Related style cards.
+
+### Layout Library Integration
+
+- Modify: `src/components/style-preset/AppliedStyleStrip.tsx`
+ - Point to selected `DesignStyle`.
+ - Link to `/design-styles`.
+- Modify: `src/components/web-layout/LayoutPreviewRenderer.tsx`
+ - Continue using CSS variables.
+ - No layout data changes required.
+- Modify: `src/app/web-layouts/page.tsx`
+ - Keep layout-first content.
+ - Keep compact applied design format strip only.
+
+### Later Image Generation Milestone
+
+- Create: `src/app/api/generate-design-image/route.ts`
+- Create: `src/app/admin/generate-design-images/page.tsx`
+- Create: `src/components/design-style/AdminImageGenerator.tsx`
+- Create: `src/lib/openai-image.ts`
+- Add generated output path: `public/generated/design-styles/{slug}.webp`
+- Modify: `README.md`
+ - Add environment variables and generation workflow.
+
+---
+
+## Milestone Checklist
+
+- [ ] **M0: Plan Document**
+ - [x] Create this plan.
+ - [x] Keep this document updated after each completed task.
+
+- [x] **M1: Data Foundation**
+ - [x] Create final `DesignStyle` type.
+ - [x] Add all 88 styles.
+ - [x] Add category/tag helpers.
+ - [x] Add related-style links.
+ - [x] Replace temporary `stylePresets` imports.
+
+- [x] **M2: Design Style Listing Page**
+ - [x] Build search/filter UI.
+ - [x] Build category navigation.
+ - [x] Build style cards with palette and sample thumbnail.
+ - [x] Verify mobile layout.
+
+- [x] **M3: Design Style Detail Pages**
+ - [x] Add `/design-styles/[slug]`.
+ - [x] Add metadata and static params.
+ - [x] Add detail sections.
+ - [x] Add related styles.
+
+- [x] **M4: Webpage-Like Style Samples**
+ - [x] Implement 10 sample renderers.
+ - [x] Map all 88 styles to sample renderers.
+ - [x] Verify at least one style per category visually.
+
+- [x] **M5: Layout Library Integration**
+ - [x] Select design style from `/design-styles`.
+ - [x] Apply selected style to `/web-layouts`.
+ - [x] Apply selected style to `/web-layouts/compare`.
+ - [x] Confirm localStorage persistence.
+
+- [x] **M6: Image Generation**
+ - [x] Add server API route.
+ - [x] Add admin page.
+ - [x] Save generated images under `public/generated/design-styles`.
+ - [x] Add clear API error handling.
+
+- [x] **M7: Docs, QA, Release**
+ - [x] Update README.
+ - [x] Run `npm run lint`.
+ - [x] Run `npm run build`.
+ - [x] Capture visual checks for `/design-styles`, `/design-styles/[slug]`, `/web-layouts`.
+ - [x] Commit.
+ - [x] Tag next version.
+ - [x] Push to GitHub.
+
+---
+
+## Task 1: Data Foundation
+
+**Files:**
+- Create: `src/data/designStyles.ts`
+- Modify: `src/components/style-preset/StylePresetProvider.tsx`
+- Modify: `src/components/style-preset/StylePresetPanel.tsx`
+- Modify: `src/components/style-preset/AppliedStyleStrip.tsx`
+- Modify: `src/lib/paletteGenerator.ts`
+- Delete after migration: `src/data/stylePresets.ts`
+
+- [x] **Step 1: Create final data file**
+
+Create `src/data/designStyles.ts` with the final `DesignStyle` type, helper functions, and the 88-style array.
+
+Required exports:
+
+```ts
+export type DesignStylePalette = {
+ base: string;
+ surface: string;
+ text: string;
+ mutedText: string;
+ primary: string;
+ accent: string;
+ accent2: string;
+ accent3: string;
+ border: string;
+};
+
+export type DesignStyleSampleType =
+ | "minimal-editorial"
+ | "brutalist-poster"
+ | "retro-commerce"
+ | "cyber-dashboard"
+ | "luxury-product"
+ | "organic-brand"
+ | "kawaii-app"
+ | "street-campaign"
+ | "magazine-layout"
+ | "saas-landing";
+
+export type DesignStyle = {
+ slug: string;
+ nameKo: string;
+ nameEn: string;
+ category: string;
+ summary: string;
+ description: string;
+ visualFeatures: string[];
+ colorPalette: string[];
+ typography: string[];
+ layoutTraits: string[];
+ useCases: string[];
+ goodFor: string[];
+ cautions: string[];
+ tags: string[];
+ related: string[];
+ palette: DesignStylePalette;
+ imagePrompt: string;
+ sampleType: DesignStyleSampleType;
+};
+
+export const defaultDesignStyleSlug = "brutalism";
+export const designStyles: DesignStyle[] = [];
+export const designStyleCategories = [...new Set(designStyles.map((style) => style.category))];
+export const designStyleTags = [...new Set(designStyles.flatMap((style) => style.tags))].sort();
+
+export function getDesignStyleBySlug(slug: string) {
+ return designStyles.find((style) => style.slug === slug);
+}
+
+export function getRelatedDesignStyles(slugs: string[]) {
+ return slugs
+ .map((slug) => getDesignStyleBySlug(slug))
+ .filter((style): style is DesignStyle => Boolean(style));
+}
+```
+
+- [x] **Step 2: Fill all 88 styles**
+
+Add the 88 styles listed in the "Required 88 Styles" checklist. Each item must have:
+
+- `summary`: one sentence.
+- `description`: 3-5 Korean sentences.
+- `visualFeatures`: 4 items.
+- `colorPalette`: 4 items.
+- `typography`: 3 items.
+- `layoutTraits`: 4 items.
+- `useCases`: 4 items.
+- `goodFor`: 4 items.
+- `cautions`: 3 items.
+- `tags`: 4-6 items.
+- `related`: 3 slugs.
+- `palette`: 9 hex colors.
+- `imagePrompt`: English prompt ending with `clean composition, high-quality design reference image, no logo, no watermark, suitable for a design encyclopedia card`.
+- `sampleType`: one of the 10 renderer types.
+
+- [x] **Step 3: Verify 88 count**
+
+Run:
+
+```powershell
+@'
+const { designStyles } = require("./.next/server/app/design-styles/page.js");
+console.log(designStyles.length);
+'@ | node -
+```
+
+If this command is awkward because the app build output is not available, use this TypeScript-safe check instead after `npm run build`:
+
+```powershell
+npm run build
+```
+
+Expected:
+
+```text
+✓ Compiled successfully
+```
+
+- [x] **Step 4: Replace imports**
+
+Replace imports from:
+
+```ts
+import { stylePresets } from "@/data/stylePresets";
+```
+
+with:
+
+```ts
+import { designStyles } from "@/data/designStyles";
+```
+
+Replace `StylePreset` type names with `DesignStyle` type names where the code now handles full design styles.
+
+- [x] **Step 5: Run lint**
+
+Run:
+
+```powershell
+npm run lint
+```
+
+Expected:
+
+```text
+> design_pan@... lint
+> eslint
+```
+
+with exit code `0`.
+
+---
+
+## Task 2: Listing Page
+
+**Files:**
+- Modify: `src/app/design-styles/page.tsx`
+- Create: `src/components/design-style/DesignStyleLibrary.tsx`
+- Create: `src/components/design-style/DesignStyleFilters.tsx`
+- Create: `src/components/design-style/DesignStyleCard.tsx`
+- Create: `src/components/design-style/ColorPaletteGrid.tsx`
+
+- [x] **Step 1: Create `ColorPaletteGrid`**
+
+`src/components/design-style/ColorPaletteGrid.tsx`:
+
+```tsx
+import type { DesignStylePalette } from "@/data/designStyles";
+
+const paletteEntries: Array<[keyof DesignStylePalette, string]> = [
+ ["base", "Base"],
+ ["surface", "Surface"],
+ ["text", "Text"],
+ ["primary", "Primary"],
+ ["accent", "Accent"],
+ ["accent2", "Accent 2"],
+ ["accent3", "Accent 3"],
+ ["border", "Border"],
+];
+
+export function ColorPaletteGrid({ palette }: { palette: DesignStylePalette }) {
+ return (
+
+ {paletteEntries.map(([key, label]) => (
+
+ ))}
+
+ );
+}
+```
+
+- [x] **Step 2: Create card component**
+
+`src/components/design-style/DesignStyleCard.tsx`:
+
+```tsx
+import Link from "next/link";
+import type { DesignStyle } from "@/data/designStyles";
+import { ColorPaletteGrid } from "@/components/design-style/ColorPaletteGrid";
+import { DesignStyleSampleRenderer } from "@/components/design-style/DesignStyleSampleRenderer";
+
+export function DesignStyleCard({ style }: { style: DesignStyle }) {
+ return (
+
+
+
+
+
+
+
{style.category}
+
+ {style.nameKo}
+
+
{style.nameEn}
+
+ {style.summary}
+
+
+
+
+ {style.tags.slice(0, 4).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ 자세히 보기
+
+
+
+ );
+}
+```
+
+- [x] **Step 3: Create filter component**
+
+`src/components/design-style/DesignStyleFilters.tsx`:
+
+```tsx
+"use client";
+
+import { designStyleCategories, designStyleTags } from "@/data/designStyles";
+
+type Props = {
+ category: string;
+ onCategoryChange: (category: string) => void;
+ onQueryChange: (query: string) => void;
+ onReset: () => void;
+ onTagChange: (tag: string) => void;
+ query: string;
+ tag: string;
+};
+
+export function DesignStyleFilters({
+ category,
+ onCategoryChange,
+ onQueryChange,
+ onReset,
+ onTagChange,
+ query,
+ tag,
+}: Props) {
+ return (
+
+ );
+}
+```
+
+- [x] **Step 4: Create library component**
+
+`src/components/design-style/DesignStyleLibrary.tsx`:
+
+```tsx
+"use client";
+
+import { useMemo, useState } from "react";
+import { designStyles } from "@/data/designStyles";
+import { DesignStyleCard } from "@/components/design-style/DesignStyleCard";
+import { DesignStyleFilters } from "@/components/design-style/DesignStyleFilters";
+
+export function DesignStyleLibrary() {
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState("");
+ const [tag, setTag] = useState("");
+
+ const filteredStyles = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ return designStyles.filter((style) => {
+ const matchesQuery =
+ normalizedQuery.length === 0 ||
+ [
+ style.nameKo,
+ style.nameEn,
+ style.summary,
+ style.description,
+ style.category,
+ ...style.tags,
+ ...style.goodFor,
+ ]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalizedQuery);
+ const matchesCategory = category === "" || style.category === category;
+ const matchesTag = tag === "" || style.tags.includes(tag);
+
+ return matchesQuery && matchesCategory && matchesTag;
+ });
+ }, [category, query, tag]);
+
+ function resetFilters() {
+ setQuery("");
+ setCategory("");
+ setTag("");
+ }
+
+ return (
+
+
+
+
+ {filteredStyles.length}개 디자인 형식 표시 중
+
+
+
+ {filteredStyles.map((style) => (
+
+ ))}
+
+
+ );
+}
+```
+
+- [x] **Step 5: Wire page**
+
+Modify `src/app/design-styles/page.tsx` to import and render:
+
+```tsx
+import { DesignStyleLibrary } from "@/components/design-style/DesignStyleLibrary";
+
+// inside page body
+
+```
+
+- [x] **Step 6: Verify listing**
+
+Run:
+
+```powershell
+npm run lint
+npm run build
+```
+
+Expected: both commands exit with code `0`.
+
+---
+
+## Task 3: Detail Pages
+
+**Files:**
+- Create: `src/app/design-styles/[slug]/page.tsx`
+- Create: `src/components/design-style/DesignStyleDetailSection.tsx`
+- Create: `src/components/design-style/RelatedDesignStyles.tsx`
+- Modify: `src/components/design-style/DesignStyleSampleRenderer.tsx`
+
+- [x] **Step 1: Create detail section component**
+
+`src/components/design-style/DesignStyleDetailSection.tsx`:
+
+```tsx
+import type { ReactNode } from "react";
+
+export function DesignStyleDetailSection({
+ children,
+ title,
+}: {
+ children: ReactNode;
+ title: string;
+}) {
+ return (
+
+ );
+}
+```
+
+- [x] **Step 2: Create related component**
+
+`src/components/design-style/RelatedDesignStyles.tsx`:
+
+```tsx
+import { getRelatedDesignStyles } from "@/data/designStyles";
+import { DesignStyleCard } from "@/components/design-style/DesignStyleCard";
+
+export function RelatedDesignStyles({ slugs }: { slugs: string[] }) {
+ const styles = getRelatedDesignStyles(slugs);
+
+ if (styles.length === 0) return null;
+
+ return (
+
+
+ Related styles
+
+
+ {styles.map((style) => (
+
+ ))}
+
+
+ );
+}
+```
+
+- [x] **Step 3: Create detail page**
+
+`src/app/design-styles/[slug]/page.tsx`:
+
+```tsx
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { designStyles, getDesignStyleBySlug } from "@/data/designStyles";
+import { ColorPaletteGrid } from "@/components/design-style/ColorPaletteGrid";
+import { DesignStyleDetailSection } from "@/components/design-style/DesignStyleDetailSection";
+import { DesignStyleSampleRenderer } from "@/components/design-style/DesignStyleSampleRenderer";
+import { RelatedDesignStyles } from "@/components/design-style/RelatedDesignStyles";
+
+export function generateStaticParams() {
+ return designStyles.map((style) => ({ slug: style.slug }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise {
+ const { slug } = await params;
+ const style = getDesignStyleBySlug(slug);
+
+ if (!style) {
+ return {
+ title: "디자인 형식 없음",
+ };
+ }
+
+ return {
+ title: style.nameKo,
+ description: style.summary,
+ };
+}
+
+export default async function DesignStyleDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const style = getDesignStyleBySlug(slug);
+
+ if (!style) notFound();
+
+ return (
+
+
+
+ 목록으로 돌아가기
+
+
+
+
{style.category}
+
+ {style.nameKo}
+
+
{style.nameEn}
+
+ {style.summary}
+
+
+
+
+
+
+
+ {style.description}
+
+
+
+
+
+
+
+
+
+
+ {style.imagePrompt}
+
+
+
+
+
+
+
+
+ );
+}
+
+function ListBlock({ items, title }: { items: string[]; title: string }) {
+ return (
+
+
+ {items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+
+ );
+}
+```
+
+- [x] **Step 4: Verify detail routes**
+
+Run:
+
+```powershell
+npm run build
+```
+
+Expected route output includes:
+
+```text
+├ ● /design-styles/[slug]
+```
+
+---
+
+## Task 4: Webpage-Like Sample Renderer
+
+**Files:**
+- Create: `src/components/design-style/DesignStyleSampleRenderer.tsx`
+
+- [x] **Step 1: Create renderer shell**
+
+`src/components/design-style/DesignStyleSampleRenderer.tsx`:
+
+```tsx
+import type { DesignStyle } from "@/data/designStyles";
+import { cn } from "@/lib/utils";
+
+type Props = {
+ compact?: boolean;
+ style: DesignStyle;
+};
+
+export function DesignStyleSampleRenderer({ compact = false, style }: Props) {
+ const vars = {
+ "--sample-base": style.palette.base,
+ "--sample-surface": style.palette.surface,
+ "--sample-text": style.palette.text,
+ "--sample-muted": style.palette.mutedText,
+ "--sample-primary": style.palette.primary,
+ "--sample-accent": style.palette.accent,
+ "--sample-accent-2": style.palette.accent2,
+ "--sample-accent-3": style.palette.accent3,
+ "--sample-border": style.palette.border,
+ } as React.CSSProperties;
+
+ return (
+
+
+
+ );
+}
+
+function SampleByType({ compact, style }: Props) {
+ if (style.sampleType === "cyber-dashboard") return ;
+ if (style.sampleType === "luxury-product") return ;
+ if (style.sampleType === "organic-brand") return ;
+ if (style.sampleType === "kawaii-app") return ;
+ if (style.sampleType === "street-campaign") return ;
+ if (style.sampleType === "retro-commerce") return ;
+ if (style.sampleType === "magazine-layout") return ;
+ if (style.sampleType === "saas-landing") return ;
+ if (style.sampleType === "minimal-editorial") return ;
+ return ;
+}
+```
+
+- [x] **Step 2: Add all 10 sample functions**
+
+Implement these functions in the same file:
+
+- `MinimalEditorial`
+- `BrutalistPoster`
+- `RetroCommerce`
+- `CyberDashboard`
+- `LuxuryProduct`
+- `OrganicBrand`
+- `KawaiiApp`
+- `StreetCampaign`
+- `MagazineLayout`
+- `SaasLanding`
+
+Each sample must:
+
+- Use `style.palette` through CSS variables.
+- Show a header.
+- Show a large style-specific title.
+- Show at least one actual content/product/dashboard/editorial section.
+- Avoid generic blank wireframes.
+- Fit inside compact card thumbnails.
+
+- [x] **Step 3: Visual test one style per category**
+
+Use Chrome screenshot or the in-app browser for these URLs:
+
+```text
+http://127.0.0.1:3001/design-styles/minimalism
+http://127.0.0.1:3001/design-styles/brutalism
+http://127.0.0.1:3001/design-styles/y2k
+http://127.0.0.1:3001/design-styles/cyberpunk
+http://127.0.0.1:3001/design-styles/luxury
+http://127.0.0.1:3001/design-styles/organic-design
+http://127.0.0.1:3001/design-styles/kawaii
+http://127.0.0.1:3001/design-styles/streetwear
+http://127.0.0.1:3001/design-styles/editorial-design
+http://127.0.0.1:3001/design-styles/glassmorphism
+```
+
+Expected:
+
+- Each page has a visibly different style.
+- No sample is a plain box-only wireframe.
+- Text does not overflow at 390px mobile width.
+
+---
+
+## Task 5: Layout Library Integration
+
+**Files:**
+- Modify: `src/components/style-preset/StylePresetProvider.tsx`
+- Modify: `src/components/style-preset/AppliedStyleStrip.tsx`
+- Modify: `src/app/globals.css`
+- Modify: `src/components/web-layout/LayoutPreviewRenderer.tsx`
+
+- [ ] **Step 1: Use selected `DesignStyle` for CSS variables**
+
+Provider must write:
+
+```css
+--style-base
+--style-surface
+--style-text
+--style-muted
+--style-primary
+--style-accent
+--style-accent-2
+--style-accent-3
+--style-border
+```
+
+- [ ] **Step 2: Confirm localStorage persistence**
+
+Manual test:
+
+1. Open `/design-styles`.
+2. Select `사이버펑크`.
+3. Reload the page.
+4. Open `/web-layouts`.
+
+Expected:
+
+- Applied style strip shows `사이버펑크`.
+- Layout preview cards use dark neon colors.
+
+- [ ] **Step 3: Confirm compare page**
+
+Manual test:
+
+1. Open `/web-layouts/compare`.
+2. Confirm active preview uses selected style colors.
+3. Use left/right arrows.
+
+Expected:
+
+- All compared layouts keep the same selected design style.
+- Arrows and floating summary still fit on mobile.
+
+---
+
+## Task 6: Image Generation Milestone
+
+**Files:**
+- Create: `src/lib/openai-image.ts`
+- Create: `src/app/api/generate-design-image/route.ts`
+- Create: `src/app/admin/generate-design-images/page.tsx`
+- Create: `src/components/design-style/AdminImageGenerator.tsx`
+- Modify: `README.md`
+
+- [x] **Step 1: Add server-only OpenAI helper**
+
+`src/lib/openai-image.ts` must:
+
+- Read `process.env.OPENAI_API_KEY`.
+- Read `process.env.OPENAI_IMAGE_MODEL || "gpt-image-2"`.
+- Read `process.env.IMAGE_OUTPUT_FORMAT || "webp"`.
+- Read `process.env.IMAGE_QUALITY || "medium"`.
+- Return a clear error when key is missing.
+- Never expose API key to client components.
+
+- [x] **Step 2: Add API route**
+
+`src/app/api/generate-design-image/route.ts` must accept:
+
+```json
+{
+ "slug": "minimalism",
+ "force": false
+}
+```
+
+Expected response:
+
+```json
+{
+ "ok": true,
+ "path": "/generated/design-styles/minimalism.webp",
+ "skipped": false
+}
+```
+
+- [x] **Step 3: Add admin page**
+
+Admin page buttons:
+
+- `누락 이미지 생성`
+- `선택 이미지 재생성`
+- `전체 재생성`
+
+The page must show:
+
+- slug
+- style name
+- current image path
+- generation status
+- error message
+
+- [x] **Step 4: README environment section**
+
+Add:
+
+```text
+OPENAI_API_KEY=
+OPENAI_IMAGE_MODEL=gpt-image-2
+IMAGE_OUTPUT_FORMAT=webp
+IMAGE_QUALITY=medium
+```
+
+---
+
+## Verification Checklist
+
+- [x] `npm run lint` passes.
+- [x] `npm run build` passes.
+- [x] `/design-styles` loads.
+- [x] `/design-styles/minimalism` loads.
+- [x] `/design-styles/cyberpunk` loads.
+- [x] `/design-styles/luxury` loads.
+- [x] `/web-layouts` still loads.
+- [x] `/web-layouts/compare` still loads.
+- [x] Mobile width 390px has no horizontal page overflow.
+- [x] Design style cards look like real style samples, not plain wireframes.
+- [ ] Layout preview cards still look like real webpages.
+- [x] Search filters update instantly.
+- [x] Category filters update instantly.
+- [x] Selected style persists after reload.
+- [x] README explains how to add a new design style.
+- [x] README explains image generation environment variables.
+
+---
+
+## Progress Log
+
+- [x] 2026-06-02: User clarified the goal is all styles from the pasted prompt, each as part of a Design Style Library.
+- [x] 2026-06-02: Created this implementation plan.
+- [x] M1 complete.
+- [x] 2026-06-02: M1 data foundation complete: `designStyles` has 88 unique styles, `npm run lint` passed, and `npm run build` passed.
+- [x] M2 complete.
+- [x] 2026-06-02: M2 listing page complete: added filters, library cards, palette grid, 10 style sample renderers, `npm run lint` passed, and `npm run build` passed.
+- [x] M3 complete.
+- [x] 2026-06-02: M3 detail pages complete: `/design-styles/[slug]` statically builds 88 routes, key detail URLs return 200, mobile viewport is `width=device-width`, and 390px `scrollWidth` equals `clientWidth`.
+- [x] M4 complete.
+- [x] 2026-06-02: M4 sample verification complete: captured a 10-style contact sheet for one representative per category under `.next/codex-screens/style-categories/category-contact-sheet.png`.
+- [x] M5 complete.
+- [x] 2026-06-02: M5 integration complete: applying `cyberpunk` from `/design-styles` stores `selectedSlug`, `/web-layouts` and `/web-layouts/compare` both hydrate to `data-style-preset="cyberpunk"` with accent `#00E5FF`.
+- [x] M6 complete.
+- [x] 2026-06-02: M6 image generation complete: added `/api/design-style-images`, `/design-styles/generate`, local save path, and verified missing `OPENAI_API_KEY` returns 503 with a clear error.
+- [x] M7 complete.
+- [x] 2026-06-02: M7 release complete: README and project skill updated, `npm run lint` and `npm run build` pass on `0.1.4`, visual checks captured, and release is prepared for GitHub tag `v0.1.4`.
diff --git a/package-lock.json b/package-lock.json
index c3a72e9..251bf54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,15 @@
{
- "name": "design_pan",
- "version": "0.1.3",
+ "name": "openlayout",
+ "version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "design_pan",
- "version": "0.1.3",
+ "name": "openlayout",
+ "version": "0.1.4",
+ "license": "MIT",
"dependencies": {
- "next": "16.2.6",
+ "next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@@ -1055,9 +1056,9 @@
}
},
"node_modules/@next/env": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
- "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz",
+ "integrity": "sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1071,9 +1072,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
- "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.7.tgz",
+ "integrity": "sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==",
"cpu": [
"arm64"
],
@@ -1087,9 +1088,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
- "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.7.tgz",
+ "integrity": "sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==",
"cpu": [
"x64"
],
@@ -1103,12 +1104,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
- "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.7.tgz",
+ "integrity": "sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1119,12 +1123,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
- "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.7.tgz",
+ "integrity": "sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1135,12 +1142,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
- "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.7.tgz",
+ "integrity": "sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1151,12 +1161,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
- "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.7.tgz",
+ "integrity": "sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1167,9 +1180,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
- "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.7.tgz",
+ "integrity": "sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==",
"cpu": [
"arm64"
],
@@ -1183,9 +1196,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
- "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.7.tgz",
+ "integrity": "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==",
"cpu": [
"x64"
],
@@ -5034,12 +5047,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
- "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.7.tgz",
+ "integrity": "sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.2.6",
+ "@next/env": "16.2.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -5053,14 +5066,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.2.6",
- "@next/swc-darwin-x64": "16.2.6",
- "@next/swc-linux-arm64-gnu": "16.2.6",
- "@next/swc-linux-arm64-musl": "16.2.6",
- "@next/swc-linux-x64-gnu": "16.2.6",
- "@next/swc-linux-x64-musl": "16.2.6",
- "@next/swc-win32-arm64-msvc": "16.2.6",
- "@next/swc-win32-x64-msvc": "16.2.6",
+ "@next/swc-darwin-arm64": "16.2.7",
+ "@next/swc-darwin-x64": "16.2.7",
+ "@next/swc-linux-arm64-gnu": "16.2.7",
+ "@next/swc-linux-arm64-musl": "16.2.7",
+ "@next/swc-linux-x64-gnu": "16.2.7",
+ "@next/swc-linux-x64-musl": "16.2.7",
+ "@next/swc-win32-arm64-msvc": "16.2.7",
+ "@next/swc-win32-x64-msvc": "16.2.7",
"sharp": "^0.34.5"
},
"peerDependencies": {
@@ -5086,34 +5099,6 @@
}
}
},
- "node_modules/next/node_modules/postcss": {
- "version": "8.4.31",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
- "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.6",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
"node_modules/node-exports-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5407,7 +5392,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
- "dev": true,
"funding": [
{
"type": "opencollective",
diff --git a/package.json b/package.json
index 7b1b4ec..165c15d 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,34 @@
{
- "name": "design_pan",
- "version": "0.1.3",
- "private": true,
+ "name": "openlayout",
+ "version": "0.1.4",
+ "private": false,
+ "description": "A web layout and design style library for choosing, comparing, and previewing website structures.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/pandaofwild/openlayout.git"
+ },
+ "bugs": {
+ "url": "https://github.com/pandaofwild/openlayout/issues"
+ },
+ "homepage": "https://github.com/pandaofwild/openlayout#readme",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
+ "engines": {
+ "node": ">=22"
+ },
"dependencies": {
- "next": "16.2.6",
+ "next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+ "overrides": {
+ "postcss": "8.5.15"
+ },
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
diff --git a/skills/design-style-recommender/SKILL.md b/skills/design-style-recommender/SKILL.md
new file mode 100644
index 0000000..e00baae
--- /dev/null
+++ b/skills/design-style-recommender/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: design-style-recommender
+description: Use when recommending openlayout design styles by brand tone, audience, industry, visual intensity, typography needs, palette direction, or when adding/refining design style catalog entries in this project.
+---
+
+# Design Style Recommender
+
+Use this skill inside openlayout when a user needs a visual style direction before applying it to a layout.
+
+## Source of Truth
+
+- Design style data lives in `src/data/designStyles.ts`.
+- Prefer existing `designStyles` entries before inventing a new style.
+- Match by `category`, `summary`, `description`, `tags`, `goodFor`, `useCases`, `palette`, and `sampleType`.
+- When recommending a concrete item, include its route: `/design-styles/{slug}`.
+
+## Recommendation Workflow
+
+1. Extract brand tone, audience, industry, content density, desired emotion, typography needs, palette direction, and risk tolerance.
+2. Search `src/data/designStyles.ts` for matching Korean/English terms in `nameKo`, `nameEn`, `category`, `tags`, `goodFor`, and `useCases`.
+3. Pick one primary style and 2-3 alternatives.
+4. Include one style to avoid when contrast, credibility, accessibility, or implementation risk is obvious.
+5. Explain each choice in terms of visual impression, palette, typography, layout behavior, and where it fits best.
+
+Ask a short clarification only when the brand or page goal is unclear. Otherwise infer conservatively and move.
+
+## Quick Heuristics
+
+- Quiet premium, portfolio, architecture, editorial brand: start with `minimalism`, `high-end-minimal`, `luxury`, or `old-money`.
+- Bold campaign, fashion, event, art direction: start with `brutalism`, `maximalism`, `posterism`, or `streetwear`.
+- Tech, AI, gaming, developer tools: start with `cyberpunk`, `high-tech`, `ai-aesthetic`, or `dark-mode-design`.
+- Wellness, craft, local, sustainability: start with `organic-design`, `botanical`, `wabi-sabi`, or `kinfolk`.
+- Youthful, casual, app, community, goods: start with `kawaii`, `dopamine-design`, `toy-design`, or `playful-design`.
+- Content, report, magazine, archive: start with `editorial-design`, `magazine-style`, `grid-system`, or `typography-focused`.
+- Product UI, SaaS, startup landing: start with `saas-style`, `flat-design`, `material-design`, or `glassmorphism`.
+
+## Output Shape
+
+Use this format unless the user asks otherwise:
+
+```markdown
+추천: [style.nameKo] (`/design-styles/{slug}`)
+
+왜 맞는지:
+- ...
+
+대안:
+- [name] - ...
+- [name] - ...
+
+피하면 좋은 스타일:
+- [name or category] - ...
+
+프롬프트에 넣을 디자인 조건:
+- ...
+```
+
+## When Editing the Catalog
+
+- Add new entries in `styleSeedTuples`.
+- Add a new category only when existing categories cannot explain the visual family.
+- Add a palette override in `palettes` when the generated palette bank is not distinctive enough.
+- Add a new `sampleType` only when no existing renderer communicates the style as a webpage.
+- After code or data edits, run `npm run lint` and `npm run build`.
diff --git a/src/app/api/design-style-images/route.ts b/src/app/api/design-style-images/route.ts
new file mode 100644
index 0000000..02e639e
--- /dev/null
+++ b/src/app/api/design-style-images/route.ts
@@ -0,0 +1,118 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { NextResponse } from "next/server";
+import { getDesignStyleBySlug } from "@/data/designStyles";
+
+export const runtime = "nodejs";
+
+type GenerateImageRequest = {
+ prompt?: string;
+ quality?: string;
+ size?: string;
+ slug?: string;
+};
+
+type OpenAIImageResponse = {
+ data?: Array<{
+ b64_json?: string;
+ revised_prompt?: string;
+ url?: string;
+ }>;
+ error?: {
+ message?: string;
+ };
+};
+
+const allowedQualities = new Set(["auto", "low", "medium", "high"]);
+const allowedSizes = new Set(["auto", "1024x1024", "1536x1024", "1024x1536"]);
+
+function jsonError(message: string, status: number) {
+ return NextResponse.json({ error: message }, { status });
+}
+
+function cleanText(input: unknown) {
+ return typeof input === "string" ? input.trim() : "";
+}
+
+export async function POST(request: Request) {
+ if (process.env.NODE_ENV !== "development") {
+ return jsonError("Image generation is only available in local development.", 404);
+ }
+
+ const apiKey = process.env.OPENAI_API_KEY;
+
+ if (!apiKey) {
+ return jsonError("OPENAI_API_KEY is required to generate design style images.", 503);
+ }
+
+ let body: GenerateImageRequest;
+
+ try {
+ body = (await request.json()) as GenerateImageRequest;
+ } catch {
+ return jsonError("Request body must be valid JSON.", 400);
+ }
+
+ const slug = cleanText(body.slug);
+ const style = getDesignStyleBySlug(slug);
+
+ if (!style) {
+ return jsonError("Unknown design style slug.", 404);
+ }
+
+ const quality = allowedQualities.has(cleanText(body.quality)) ? cleanText(body.quality) : "medium";
+ const size = allowedSizes.has(cleanText(body.size)) ? cleanText(body.size) : "1024x1024";
+ const customPrompt = cleanText(body.prompt);
+ const prompt = [style.imagePrompt, customPrompt].filter(Boolean).join(". ").slice(0, 32000);
+ const model = process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
+ const outputFormat = "webp";
+
+ const openaiResponse = await fetch("https://api.openai.com/v1/images/generations", {
+ body: JSON.stringify({
+ background: "opaque",
+ model,
+ output_format: outputFormat,
+ prompt,
+ quality,
+ size,
+ }),
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+ const result = (await openaiResponse.json()) as OpenAIImageResponse;
+
+ if (!openaiResponse.ok) {
+ return jsonError(result.error?.message ?? "OpenAI image generation failed.", openaiResponse.status);
+ }
+
+ const image = result.data?.[0];
+
+ if (!image?.b64_json && !image?.url) {
+ return jsonError("OpenAI response did not include image data.", 502);
+ }
+
+ const bytes = image.b64_json
+ ? Buffer.from(image.b64_json, "base64")
+ : Buffer.from(await (await fetch(image.url as string)).arrayBuffer());
+ const outputDir = path.join(process.cwd(), "public", "generated", "design-styles");
+ const fileName = `${style.slug}.${outputFormat}`;
+ const filePath = path.join(outputDir, fileName);
+
+ await mkdir(outputDir, { recursive: true });
+ await writeFile(filePath, bytes);
+
+ return NextResponse.json({
+ model,
+ path: `/generated/design-styles/${fileName}`,
+ prompt,
+ revisedPrompt: image.revised_prompt ?? null,
+ style: {
+ nameEn: style.nameEn,
+ nameKo: style.nameKo,
+ slug: style.slug,
+ },
+ });
+}
diff --git a/src/app/design-styles/[slug]/page.tsx b/src/app/design-styles/[slug]/page.tsx
new file mode 100644
index 0000000..42f9b2b
--- /dev/null
+++ b/src/app/design-styles/[slug]/page.tsx
@@ -0,0 +1,123 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { designStyles, getDesignStyleBySlug } from "@/data/designStyles";
+import { ColorPaletteGrid } from "@/components/design-style/ColorPaletteGrid";
+import { DesignStyleDetailSection } from "@/components/design-style/DesignStyleDetailSection";
+import { DesignStyleSampleRenderer } from "@/components/design-style/DesignStyleSampleRenderer";
+import { RelatedDesignStyles } from "@/components/design-style/RelatedDesignStyles";
+
+export function generateStaticParams() {
+ return designStyles.map((style) => ({ slug: style.slug }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise {
+ const { slug } = await params;
+ const style = getDesignStyleBySlug(slug);
+
+ if (!style) {
+ return {
+ title: "디자인 형식 없음",
+ };
+ }
+
+ return {
+ description: style.summary,
+ title: `${style.nameKo} | Design Style Library`,
+ };
+}
+
+export default async function DesignStyleDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const style = getDesignStyleBySlug(slug);
+
+ if (!style) {
+ notFound();
+ }
+
+ return (
+
+
+
+ 목록으로 돌아가기
+
+
+
+
+
+
+ {style.description}
+
+
+
+
+
+
+
+
+
+
+
+ {style.imagePrompt}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ListBlock({ items, title }: { items: string[]; title: string }) {
+ return (
+
+
+ {items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/design-styles/generate/page.tsx b/src/app/design-styles/generate/page.tsx
new file mode 100644
index 0000000..1cb4273
--- /dev/null
+++ b/src/app/design-styles/generate/page.tsx
@@ -0,0 +1,35 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { DesignStyleImageGenerator } from "@/components/design-style/DesignStyleImageGenerator";
+
+export const metadata: Metadata = {
+ description: "디자인 형식별 참조 이미지를 OpenAI Image API로 생성하는 로컬 관리자 화면",
+ title: "Design Style Image Generator",
+};
+
+export default function DesignStyleGeneratePage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/design-styles/page.tsx b/src/app/design-styles/page.tsx
new file mode 100644
index 0000000..72196f6
--- /dev/null
+++ b/src/app/design-styles/page.tsx
@@ -0,0 +1,69 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { designStyleCategories, designStyles } from "@/data/designStyles";
+import { DesignStyleLibrary } from "@/components/design-style/DesignStyleLibrary";
+
+export const metadata: Metadata = {
+ title: "Design Style Library",
+ description: "레이아웃 예시에 적용할 수 있는 디자인 형식과 색상표 프리셋",
+};
+
+const categories = designStyleCategories;
+
+export default function DesignStylesPage() {
+ return (
+
+
+
+
+
+ {designStyles.length} formats / {categories.length} categories
+
+
+ Design
+
+ Styles
+
+
+
+
+ 디자인 형식은 레이아웃 구조와 분리해 고릅니다. 여기서 시각 언어와
+ 색상표를 정하면 레이아웃 사전의 모든 예시 화면에 같은 톤이 적용됩니다.
+
+
+
+ 레이아웃으로 돌아가기
+
+
+ 비교에서 보기
+
+
+
+
+
+
+
+
+ {categories.map((category, index) => (
+
+
+ 0{index + 1}
+
+
{category}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 727bbf4..9c29d2a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -393,6 +393,94 @@ main header {
background-color: rgb(255 137 169 / 0.72) !important;
}
+.style-preset-root .raw-preview-canvas,
+.style-preset-root .raw-wireframe {
+ background:
+ radial-gradient(circle at 18% 18%, rgb(var(--style-accent-rgb) / 0.26), transparent 36%),
+ radial-gradient(circle at 82% 28%, rgb(var(--style-accent-2-rgb) / 0.22), transparent 38%),
+ radial-gradient(circle at 48% 92%, rgb(var(--style-accent-3-rgb) / 0.2), transparent 34%),
+ var(--style-base) !important;
+ color: var(--style-text) !important;
+}
+
+.style-preset-root .raw-preview-canvas::before,
+.style-preset-root .raw-wireframe::before {
+ background: rgb(var(--style-accent-rgb) / 0.34) !important;
+}
+
+.style-preset-root .raw-preview-canvas::after,
+.style-preset-root .raw-wireframe::after {
+ background: rgb(var(--style-accent-2-rgb) / 0.32) !important;
+}
+
+.style-preset-root .raw-preview-canvas :where(section, header, article, aside, div, button, pre),
+.style-preset-root .raw-wireframe :where(section, header, article, aside, div, button, pre) {
+ border-color: rgb(var(--style-border-rgb) / 0.2) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="bg-[#E4E2DD"],
+.style-preset-root .raw-preview-canvas [class*="bg-[#F0EEE8"],
+.style-preset-root .raw-preview-canvas [class*="bg-[#D9D6D0"],
+.style-preset-root .raw-wireframe [class*="bg-[#E4E2DD"],
+.style-preset-root .raw-wireframe [class*="bg-[#F0EEE8"],
+.style-preset-root .raw-wireframe [class*="bg-[#D9D6D0"] {
+ background-color: rgb(var(--style-surface-rgb) / 0.82) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="bg-[#1E1E1E"],
+.style-preset-root .raw-wireframe [class*="bg-[#1E1E1E"] {
+ background-color: var(--style-primary) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="bg-[#DB4A2B"],
+.style-preset-root .raw-wireframe [class*="bg-[#DB4A2B"] {
+ background-color: rgb(var(--style-accent-rgb) / 0.74) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="bg-[#F8A348"],
+.style-preset-root .raw-wireframe [class*="bg-[#F8A348"] {
+ background-color: rgb(var(--style-accent-2-rgb) / 0.72) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="bg-[#FF89A9"],
+.style-preset-root .raw-wireframe [class*="bg-[#FF89A9"] {
+ background-color: rgb(var(--style-accent-3-rgb) / 0.72) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="text-[#1E1E1E"],
+.style-preset-root .raw-preview-canvas [class*="text-zinc-9"],
+.style-preset-root .raw-preview-canvas [class*="text-zinc-8"],
+.style-preset-root .raw-wireframe [class*="text-[#1E1E1E"],
+.style-preset-root .raw-wireframe [class*="text-zinc-9"],
+.style-preset-root .raw-wireframe [class*="text-zinc-8"] {
+ color: var(--style-text) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="text-[#DB4A2B"],
+.style-preset-root .raw-preview-canvas [class*="text-emerald"],
+.style-preset-root .raw-preview-canvas [class*="text-rose"],
+.style-preset-root .raw-preview-canvas [class*="text-sky"],
+.style-preset-root .raw-wireframe [class*="text-[#DB4A2B"],
+.style-preset-root .raw-wireframe [class*="text-emerald"],
+.style-preset-root .raw-wireframe [class*="text-rose"],
+.style-preset-root .raw-wireframe [class*="text-sky"] {
+ color: var(--style-accent) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="text-[#E4E2DD"],
+.style-preset-root .raw-preview-canvas [class*="text-white"],
+.style-preset-root .raw-wireframe [class*="text-[#E4E2DD"],
+.style-preset-root .raw-wireframe [class*="text-white"] {
+ color: var(--style-surface) !important;
+}
+
+.style-preset-root .raw-preview-canvas [class*="border-[#1E1E1E"],
+.style-preset-root .raw-preview-canvas [class*="border-zinc"],
+.style-preset-root .raw-wireframe [class*="border-[#1E1E1E"],
+.style-preset-root .raw-wireframe [class*="border-zinc"] {
+ border-color: rgb(var(--style-border-rgb) / 0.22) !important;
+}
+
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b84220d..01ea5fa 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import Link from "next/link";
+import { StylePresetProvider } from "@/components/style-preset/StylePresetProvider";
import "./globals.css";
export const metadata: Metadata = {
@@ -10,6 +11,11 @@ export const metadata: Metadata = {
description: "웹사이트 설계에 활용할 수 있는 레이아웃 구조 사전",
};
+export const viewport: Viewport = {
+ initialScale: 1,
+ width: "device-width",
+};
+
export default function RootLayout({
children,
}: Readonly<{
@@ -18,9 +24,11 @@ export default function RootLayout({
return (
-
- {children}
-
+
+
+ {children}
+
+
);
@@ -42,6 +50,7 @@ function RawNavigation() {
{[
["Library", "/web-layouts"],
+ ["Styles", "/design-styles"],
["Compare", "/web-layouts/compare"],
["Skill", "/web-layouts#layout-skill"],
].map(([label, href]) => (
@@ -99,6 +108,7 @@ function RawFooter() {
+
diff --git a/src/components/design-style/ColorPaletteGrid.tsx b/src/components/design-style/ColorPaletteGrid.tsx
new file mode 100644
index 0000000..cf68ac3
--- /dev/null
+++ b/src/components/design-style/ColorPaletteGrid.tsx
@@ -0,0 +1,38 @@
+import type { DesignStylePalette } from "@/data/designStyles";
+import { cn } from "@/lib/utils";
+
+const paletteEntries: Array<[keyof DesignStylePalette, string]> = [
+ ["base", "Base"],
+ ["surface", "Surface"],
+ ["text", "Text"],
+ ["primary", "Primary"],
+ ["accent", "Accent"],
+ ["accent2", "Accent 2"],
+ ["accent3", "Accent 3"],
+ ["border", "Border"],
+];
+
+export function ColorPaletteGrid({
+ compact = false,
+ palette,
+}: {
+ compact?: boolean;
+ palette: DesignStylePalette;
+}) {
+ return (
+
+ {paletteEntries.map(([key, label]) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleCard.tsx b/src/components/design-style/DesignStyleCard.tsx
new file mode 100644
index 0000000..82b91df
--- /dev/null
+++ b/src/components/design-style/DesignStyleCard.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import Link from "next/link";
+import type { DesignStyle } from "@/data/designStyles";
+import { cn } from "@/lib/utils";
+import { ColorPaletteGrid } from "@/components/design-style/ColorPaletteGrid";
+import { DesignStyleSampleRenderer } from "@/components/design-style/DesignStyleSampleRenderer";
+
+type Props = {
+ isSelected: boolean;
+ onSelect: (slug: string) => void;
+ style: DesignStyle;
+};
+
+export function DesignStyleCard({ isSelected, onSelect, style }: Props) {
+ return (
+
+
+
+
+
+
+
+ {style.category}
+
+
+ {style.nameKo}
+
+
+ {style.nameEn}
+
+
+ {style.summary}
+
+
+
+
+
+
+ {style.tags.slice(0, 4).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+ 자세히
+
+
+
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleDetailSection.tsx b/src/components/design-style/DesignStyleDetailSection.tsx
new file mode 100644
index 0000000..bed1a7f
--- /dev/null
+++ b/src/components/design-style/DesignStyleDetailSection.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from "react";
+
+export function DesignStyleDetailSection({
+ children,
+ title,
+}: {
+ children: ReactNode;
+ title: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleFilters.tsx b/src/components/design-style/DesignStyleFilters.tsx
new file mode 100644
index 0000000..43170ff
--- /dev/null
+++ b/src/components/design-style/DesignStyleFilters.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { designStyleCategories, designStyleTags } from "@/data/designStyles";
+
+type Props = {
+ category: string;
+ onCategoryChange: (category: string) => void;
+ onQueryChange: (query: string) => void;
+ onReset: () => void;
+ onTagChange: (tag: string) => void;
+ query: string;
+ tag: string;
+};
+
+export function DesignStyleFilters({
+ category,
+ onCategoryChange,
+ onQueryChange,
+ onReset,
+ onTagChange,
+ query,
+ tag,
+}: Props) {
+ return (
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleImageGenerator.tsx b/src/components/design-style/DesignStyleImageGenerator.tsx
new file mode 100644
index 0000000..0b32716
--- /dev/null
+++ b/src/components/design-style/DesignStyleImageGenerator.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Image from "next/image";
+import { designStyles } from "@/data/designStyles";
+
+type GenerationResult = {
+ cacheBust?: number;
+ error?: string;
+ model?: string;
+ path?: string;
+ prompt?: string;
+ revisedPrompt?: string | null;
+ style?: {
+ nameEn: string;
+ nameKo: string;
+ slug: string;
+ };
+};
+
+export function DesignStyleImageGenerator() {
+ const [slug, setSlug] = useState("minimalism");
+ const [prompt, setPrompt] = useState("");
+ const [quality, setQuality] = useState("medium");
+ const [size, setSize] = useState("1024x1024");
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [result, setResult] = useState(null);
+ const selectedStyle = useMemo(
+ () => designStyles.find((style) => style.slug === slug) ?? designStyles[0],
+ [slug],
+ );
+
+ async function generateImage() {
+ setIsGenerating(true);
+ setResult(null);
+
+ try {
+ const response = await fetch("/api/design-style-images", {
+ body: JSON.stringify({ prompt, quality, size, slug }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+ const payload = (await response.json()) as GenerationResult;
+
+ setResult(
+ response.ok
+ ? { ...payload, cacheBust: Date.now() }
+ : { error: payload.error ?? "이미지 생성에 실패했습니다." },
+ );
+ } catch (error) {
+ setResult({ error: error instanceof Error ? error.message : "이미지 생성에 실패했습니다." });
+ } finally {
+ setIsGenerating(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image prompt
+
+ {selectedStyle?.nameEn}
+
+ {selectedStyle?.imagePrompt}
+
+ {result?.error ? (
+
+ {result.error}
+
+ ) : null}
+
+ {result?.path ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleLibrary.tsx b/src/components/design-style/DesignStyleLibrary.tsx
new file mode 100644
index 0000000..34cc8e4
--- /dev/null
+++ b/src/components/design-style/DesignStyleLibrary.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { designStyles } from "@/data/designStyles";
+import { useStylePreset } from "@/components/style-preset/StylePresetProvider";
+import { DesignStyleCard } from "@/components/design-style/DesignStyleCard";
+import { DesignStyleFilters } from "@/components/design-style/DesignStyleFilters";
+
+export function DesignStyleLibrary() {
+ const { customPreset, selectedSlug, setSelectedSlug } = useStylePreset();
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState("");
+ const [tag, setTag] = useState("");
+
+ const filteredStyles = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ return designStyles.filter((style) => {
+ const matchesQuery =
+ normalizedQuery.length === 0 ||
+ [
+ style.nameKo,
+ style.nameEn,
+ style.summary,
+ style.description,
+ style.category,
+ ...style.tags,
+ ...style.goodFor,
+ ...style.useCases,
+ ]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalizedQuery);
+ const matchesCategory = category === "" || style.category === category;
+ const matchesTag = tag === "" || style.tags.includes(tag);
+
+ return matchesQuery && matchesCategory && matchesTag;
+ });
+ }, [category, query, tag]);
+
+ function resetFilters() {
+ setQuery("");
+ setCategory("");
+ setTag("");
+ }
+
+ return (
+
+
+
+
+ {filteredStyles.length}개 디자인 형식 표시 중
+
+
+ {customPreset ? "프롬프트 팔레트가 적용 중입니다." : `${selectedSlug} 적용 중`}
+
+
+ {filteredStyles.length > 0 ? (
+
+ {filteredStyles.map((style) => (
+
+ ))}
+
+ ) : (
+
+
+ No style
+
+
+ 조건에 맞는 디자인 형식이 없습니다. 검색어나 필터를 초기화해 다시 확인하세요.
+
+
+ )}
+
+ );
+}
diff --git a/src/components/design-style/DesignStyleSampleRenderer.tsx b/src/components/design-style/DesignStyleSampleRenderer.tsx
new file mode 100644
index 0000000..6c3fa72
--- /dev/null
+++ b/src/components/design-style/DesignStyleSampleRenderer.tsx
@@ -0,0 +1,374 @@
+import type { CSSProperties, ReactNode } from "react";
+import type { DesignStyle } from "@/data/designStyles";
+import { cn } from "@/lib/utils";
+
+type Props = {
+ className?: string;
+ compact?: boolean;
+ style: DesignStyle;
+};
+
+type SampleVariables = CSSProperties & Record<`--sample-${string}`, string>;
+
+function withAlpha(color: string, alpha: string) {
+ return /^#[\da-f]{6}$/i.test(color) ? `${color}${alpha}` : color;
+}
+
+function sampleVariables(style: DesignStyle): SampleVariables {
+ const { palette } = style;
+
+ return {
+ "--sample-accent": palette.accent,
+ "--sample-accent-2": palette.accent2,
+ "--sample-accent-3": palette.accent3,
+ "--sample-base": palette.base,
+ "--sample-border": palette.border,
+ "--sample-border-soft": withAlpha(palette.border, "33"),
+ "--sample-muted": palette.mutedText,
+ "--sample-primary": palette.primary,
+ "--sample-surface": palette.surface,
+ "--sample-text": palette.text,
+ };
+}
+
+function SampleFrame({
+ children,
+ className,
+ compact,
+ style,
+}: Props & { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function MiniNav({ compact = false }: { compact?: boolean }) {
+ return (
+
+ );
+}
+
+function AccentOrb({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function MinimalEditorial({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
+ {style.category}
+
+
+ {style.nameEn}
+
+
+
+
+
+
+
+
+
+
+ {style.summary}
+
+
+
+
+ );
+}
+
+function BrutalistPoster({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
+
+ Raw format
+
+
+ {style.nameEn}
+
+
+
+
+ {style.summary}
+
+
+ →
+
+
+
+
+ );
+}
+
+function RetroCommerce({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+ New Drop
+
+
+ {[style.palette.accent, style.palette.accent2, style.palette.accent3].map((color, index) => (
+
+
+
+
+ 0{index + 1}
+
+ ))}
+
+
+
+ );
+}
+
+function CyberDashboard({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
+ {[68, 42, 86].map((width, index) => (
+
+
Node 0{index + 1}
+
+
+
+
+ ))}
+
+
+
+ {style.nameEn}
+
+
+ {[32, 72, 48, 88, 60].map((height) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function LuxuryProduct({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
Atelier edition
+
+ {style.nameEn}
+
+
+
+
+
+
+
+ );
+}
+
+function OrganicBrand({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
+
+
Natural system
+
+ {style.nameEn}
+
+
+
+ {[style.palette.accent, style.palette.accent2, style.palette.accent3].map((color) => (
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function KawaiiApp({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
★
+
+
+ {style.nameEn}
+
+
daily app
+
+
+
+ {[style.palette.accent, style.palette.accent2, style.palette.accent3].map((color) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function StreetCampaign({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
+ {style.nameEn}
+
+
+
+ drop
+ now
+
+
+
+ );
+}
+
+function MagazineLayout({ compact = false, style }: Props) {
+ return (
+
+
+
+
Issue 06
+
+ {style.nameEn}
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function SaasLanding({ compact = false, style }: Props) {
+ return (
+
+
+
+
+
+
Product system
+
+ {style.nameEn}
+
+
+
+
+ {[style.palette.accent, style.palette.accent2, style.palette.accent3].map((color, index) => (
+
+
+ 0{index + 1}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function DesignStyleSampleRenderer({ compact = false, style, className }: Props) {
+ const props = { className, compact, style };
+
+ switch (style.sampleType) {
+ case "brutalist-poster":
+ return ;
+ case "retro-commerce":
+ return ;
+ case "cyber-dashboard":
+ return ;
+ case "luxury-product":
+ return ;
+ case "organic-brand":
+ return ;
+ case "kawaii-app":
+ return ;
+ case "street-campaign":
+ return ;
+ case "magazine-layout":
+ return ;
+ case "saas-landing":
+ return ;
+ case "minimal-editorial":
+ default:
+ return ;
+ }
+}
diff --git a/src/components/design-style/RelatedDesignStyles.tsx b/src/components/design-style/RelatedDesignStyles.tsx
new file mode 100644
index 0000000..090b6cc
--- /dev/null
+++ b/src/components/design-style/RelatedDesignStyles.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { getRelatedDesignStyles } from "@/data/designStyles";
+import { useStylePreset } from "@/components/style-preset/StylePresetProvider";
+import { DesignStyleCard } from "@/components/design-style/DesignStyleCard";
+
+export function RelatedDesignStyles({ slugs }: { slugs: string[] }) {
+ const { customPreset, selectedSlug, setSelectedSlug } = useStylePreset();
+ const styles = getRelatedDesignStyles(slugs);
+
+ if (styles.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Related styles
+
+
+ 같은 범주와 샘플 유형을 기준으로 함께 비교하기 좋은 디자인 형식입니다.
+
+
+
+ {styles.map((style) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/style-preset/AppliedStyleStrip.tsx b/src/components/style-preset/AppliedStyleStrip.tsx
new file mode 100644
index 0000000..aa1e02f
--- /dev/null
+++ b/src/components/style-preset/AppliedStyleStrip.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Link from "next/link";
+import { useStylePreset } from "@/components/style-preset/StylePresetProvider";
+
+export function AppliedStyleStrip() {
+ const { activePreset, palette } = useStylePreset();
+
+ return (
+
+
+
+
Applied design format
+
+
+ {activePreset.nameKo}
+
+
{activePreset.nameEn}
+
+
+ {activePreset.summary}
+
+
+
+
+ {[palette.base, palette.surface, palette.text, palette.accent, palette.accent2, palette.accent3].map(
+ (color) => (
+
+ ),
+ )}
+
+
+ 형식 바꾸기
+
+
+
+
+ );
+}
diff --git a/src/components/style-preset/StylePresetPanel.tsx b/src/components/style-preset/StylePresetPanel.tsx
new file mode 100644
index 0000000..449ffb6
--- /dev/null
+++ b/src/components/style-preset/StylePresetPanel.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useStylePreset, stylePresets } from "@/components/style-preset/StylePresetProvider";
+import { cn } from "@/lib/utils";
+
+const paletteLabels = [
+ ["base", "Base"],
+ ["surface", "Surface"],
+ ["text", "Text"],
+ ["primary", "Primary"],
+ ["accent", "Accent"],
+ ["accent2", "Accent 2"],
+ ["accent3", "Accent 3"],
+] as const;
+
+export function StylePresetPanel() {
+ const {
+ activePreset,
+ customPreset,
+ generateCustomPreset,
+ palette,
+ prompt,
+ resetCustomPreset,
+ selectedSlug,
+ setPrompt,
+ setSelectedSlug,
+ } = useStylePreset();
+ const categories = useMemo(
+ () => [...new Set(stylePresets.map((preset) => preset.category))],
+ [],
+ );
+ const [activeCategory, setActiveCategory] = useState(
+ categories.includes(activePreset.category) ? activePreset.category : categories[0],
+ );
+ const visiblePresets = stylePresets.filter((preset) => preset.category === activeCategory);
+
+ return (
+
+
+
+
+
Design format categories
+
+ Visual format
+
+
+
+ {activePreset.category}
+
+
+
+
+ {categories.map((category) => {
+ const active = activeCategory === category;
+
+ return (
+
+ );
+ })}
+
+
+
+ {visiblePresets.map((preset) => {
+ const active = !customPreset && selectedSlug === preset.slug;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function SparkIcon() {
+ return (
+
+ );
+}
diff --git a/src/components/style-preset/StylePresetProvider.tsx b/src/components/style-preset/StylePresetProvider.tsx
new file mode 100644
index 0000000..819f199
--- /dev/null
+++ b/src/components/style-preset/StylePresetProvider.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+ type ReactNode,
+} from "react";
+import {
+ defaultDesignStyleSlug,
+ designStyles,
+ getDesignStyleBySlug,
+ type DesignStyle,
+ type DesignStylePalette,
+} from "@/data/designStyles";
+import { createPromptStylePreset } from "@/lib/paletteGenerator";
+
+type StylePresetContextValue = {
+ activePreset: DesignStyle;
+ customPreset: DesignStyle | null;
+ generateCustomPreset: () => void;
+ palette: DesignStylePalette;
+ prompt: string;
+ resetCustomPreset: () => void;
+ selectedSlug: string;
+ setPrompt: (prompt: string) => void;
+ setSelectedSlug: (slug: string) => void;
+};
+
+type StyleVariables = CSSProperties & Record<`--style-${string}`, string>;
+
+const StylePresetContext = createContext(null);
+const storageKey = "openlayout-style-preset-v1";
+
+const defaultPrompt =
+ "브랜드, 웹, 그래픽, UI 디자인에 활용할 수 있는 디자인 풍 사전. 대표 이미지는 카드 썸네일처럼 명확하고 색감이 강해야 한다.";
+
+type StoredStyleState = {
+ customPreset: DesignStyle | null;
+ prompt: string;
+ selectedSlug: string;
+};
+
+type StyleState = StoredStyleState & {
+ storageReady: boolean;
+};
+
+const defaultStyleState: StoredStyleState = {
+ customPreset: null,
+ prompt: defaultPrompt,
+ selectedSlug: defaultDesignStyleSlug,
+};
+
+const defaultClientStyleState: StyleState = {
+ ...defaultStyleState,
+ storageReady: false,
+};
+
+function hexToRgb(hex: string) {
+ const normalized = hex.replace("#", "");
+ const fallback = normalized.length === 3
+ ? normalized
+ .split("")
+ .map((value) => value + value)
+ .join("")
+ : normalized;
+ const value = Number.parseInt(fallback, 16);
+
+ if (Number.isNaN(value)) return "30 30 30";
+
+ return `${(value >> 16) & 255} ${(value >> 8) & 255} ${value & 255}`;
+}
+
+function styleVariablesFor(palette: DesignStylePalette): StyleVariables {
+ return {
+ "--style-base": palette.base,
+ "--style-surface": palette.surface,
+ "--style-text": palette.text,
+ "--style-muted": palette.mutedText,
+ "--style-primary": palette.primary,
+ "--style-accent": palette.accent,
+ "--style-accent-2": palette.accent2,
+ "--style-accent-3": palette.accent3,
+ "--style-border": palette.border,
+ "--style-base-rgb": hexToRgb(palette.base),
+ "--style-surface-rgb": hexToRgb(palette.surface),
+ "--style-text-rgb": hexToRgb(palette.text),
+ "--style-primary-rgb": hexToRgb(palette.primary),
+ "--style-accent-rgb": hexToRgb(palette.accent),
+ "--style-accent-2-rgb": hexToRgb(palette.accent2),
+ "--style-accent-3-rgb": hexToRgb(palette.accent3),
+ "--style-border-rgb": hexToRgb(palette.border),
+ };
+}
+
+function readStoredStyleState(): StoredStyleState {
+ if (typeof window === "undefined") return defaultStyleState;
+
+ const saved = window.localStorage.getItem(storageKey);
+ if (!saved) return defaultStyleState;
+
+ try {
+ const parsed = JSON.parse(saved) as Partial;
+
+ return {
+ customPreset: parsed.customPreset ?? null,
+ prompt: parsed.prompt ?? defaultPrompt,
+ selectedSlug: parsed.selectedSlug ?? defaultDesignStyleSlug,
+ };
+ } catch {
+ window.localStorage.removeItem(storageKey);
+ return defaultStyleState;
+ }
+}
+
+function findStylePreset(slug: string): DesignStyle {
+ const style = getDesignStyleBySlug(slug) ?? designStyles[0];
+
+ if (!style) {
+ throw new Error("designStyles must include at least one style");
+ }
+
+ return style;
+}
+
+export function StylePresetProvider({ children }: { children: ReactNode }) {
+ const [styleState, setStyleState] = useState(defaultClientStyleState);
+ const userChangedBeforeStorageReady = useRef(false);
+ const { customPreset, prompt, selectedSlug, storageReady } = styleState;
+
+ const selectedPreset = findStylePreset(selectedSlug);
+ const activePreset = customPreset ?? selectedPreset;
+
+ useEffect(() => {
+ // localStorage is browser-only, so persisted style must hydrate after mount.
+ setStyleState((current) =>
+ userChangedBeforeStorageReady.current
+ ? { ...current, storageReady: true }
+ : { ...readStoredStyleState(), storageReady: true },
+ );
+ }, []);
+
+ useEffect(() => {
+ if (!storageReady) return;
+
+ window.localStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ customPreset,
+ prompt,
+ selectedSlug,
+ }),
+ );
+ }, [customPreset, prompt, selectedSlug, storageReady]);
+
+ const value = useMemo(
+ () => ({
+ activePreset,
+ customPreset,
+ generateCustomPreset: () => {
+ userChangedBeforeStorageReady.current = true;
+ setStyleState((current) => ({
+ ...current,
+ customPreset: createPromptStylePreset(current.prompt, selectedPreset),
+ }));
+ },
+ palette: activePreset.palette,
+ prompt,
+ resetCustomPreset: () => {
+ userChangedBeforeStorageReady.current = true;
+ setStyleState((current) => ({
+ ...current,
+ customPreset: null,
+ }));
+ },
+ selectedSlug,
+ setPrompt: (nextPrompt: string) => {
+ userChangedBeforeStorageReady.current = true;
+ setStyleState((current) => ({ ...current, prompt: nextPrompt }));
+ },
+ setSelectedSlug: (slug: string) => {
+ userChangedBeforeStorageReady.current = true;
+ setStyleState((current) => ({
+ ...current,
+ customPreset: null,
+ selectedSlug: slug,
+ }));
+ },
+ }),
+ [activePreset, customPreset, prompt, selectedPreset, selectedSlug],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useStylePreset() {
+ const value = useContext(StylePresetContext);
+
+ if (!value) {
+ throw new Error("useStylePreset must be used inside StylePresetProvider");
+ }
+
+ return value;
+}
+
+export { designStyles as stylePresets };
diff --git a/src/data/designStyles.ts b/src/data/designStyles.ts
new file mode 100644
index 0000000..d92e982
--- /dev/null
+++ b/src/data/designStyles.ts
@@ -0,0 +1,517 @@
+export type DesignStylePalette = {
+ base: string;
+ surface: string;
+ text: string;
+ mutedText: string;
+ primary: string;
+ accent: string;
+ accent2: string;
+ accent3: string;
+ border: string;
+};
+
+export type DesignStyleSampleType =
+ | "minimal-editorial"
+ | "brutalist-poster"
+ | "retro-commerce"
+ | "cyber-dashboard"
+ | "luxury-product"
+ | "organic-brand"
+ | "kawaii-app"
+ | "street-campaign"
+ | "magazine-layout"
+ | "saas-landing";
+
+export type DesignStyle = {
+ slug: string;
+ nameKo: string;
+ nameEn: string;
+ category: string;
+ summary: string;
+ description: string;
+ visualFeatures: string[];
+ colorPalette: string[];
+ typography: string[];
+ layoutTraits: string[];
+ useCases: string[];
+ goodFor: string[];
+ cautions: string[];
+ tags: string[];
+ related: string[];
+ palette: DesignStylePalette;
+ imagePrompt: string;
+ sampleType: DesignStyleSampleType;
+};
+
+type CategoryProfile = {
+ caution: string;
+ colorNotes: string[];
+ goodFor: string[];
+ layout: string[];
+ sampleType: DesignStyleSampleType;
+ typography: string[];
+ useCases: string[];
+ visual: string[];
+};
+
+type DesignStyleSeed = {
+ category: string;
+ nameEn: string;
+ nameKo: string;
+ palette: DesignStylePalette;
+ sampleType?: DesignStyleSampleType;
+ slug: string;
+ tags: string[];
+ tone: string;
+};
+
+type DesignStyleSeedTuple = [
+ slug: string,
+ nameKo: string,
+ nameEn: string,
+ category: string,
+ tone: string,
+ tags: string[],
+ sampleType: DesignStyleSampleType,
+];
+
+const promptSuffix =
+ "clean composition, high-quality design reference image, no logo, no watermark, suitable for a design encyclopedia card";
+
+const categoryProfiles: Record = {
+ "모던 / 미니멀": {
+ caution: "표현을 줄이는 만큼 브랜드만의 기억점이 사라지지 않도록 한두 개의 강한 시각 신호를 남겨야 합니다.",
+ colorNotes: ["절제된 중성 배경", "낮은 채도의 보조색", "검정 또는 짙은 회색 본문", "작은 면적의 따뜻한 포인트"],
+ goodFor: ["브랜드 소개", "스튜디오 포트폴리오", "문서형 서비스", "프리미엄 랜딩페이지"],
+ layout: ["넓은 여백", "명확한 단일 컬럼", "얇은 경계선", "적은 수의 CTA"],
+ sampleType: "minimal-editorial",
+ typography: ["정제된 산세리프 제목", "작은 메타 라벨", "읽기 쉬운 본문 행간"],
+ useCases: ["회사 소개", "제품 철학 페이지", "포트폴리오", "콘텐츠 허브"],
+ visual: ["불필요한 장식을 덜어낸 화면", "정돈된 여백", "명료한 정보 위계", "섬세한 표면 대비"],
+ },
+ "강렬 / 실험": {
+ caution: "의도적인 불편함과 혼란이 탐색 실패로 이어지지 않도록 핵심 CTA와 정보 경로는 분명하게 고정해야 합니다.",
+ colorNotes: ["고대비 배경", "강한 원색 포인트", "검정과 흰색의 직접 대비", "충돌감 있는 보조색"],
+ goodFor: ["브랜드 캠페인", "패션 룩북", "전시 홍보", "실험적 포트폴리오"],
+ layout: ["비대칭 그리드", "큰 타이포 블록", "겹친 섹션", "강한 경계선"],
+ sampleType: "brutalist-poster",
+ typography: ["무거운 디스플레이 타입", "압축된 대문자 라벨", "의도적으로 큰 제목"],
+ useCases: ["캠페인 첫 화면", "포스터형 랜딩", "아티스트 페이지", "행사 소개"],
+ visual: ["거친 대비", "실험적 배치", "강한 타이포그래피", "의도적인 긴장감"],
+ },
+ "레트로 / 빈티지": {
+ caution: "복고 표현이 단순한 장식으로 보이지 않게 실제 콘텐츠 구조와 상호작용은 현대적인 기준으로 유지해야 합니다.",
+ colorNotes: ["오래된 인쇄물 같은 배경", "바랜 원색", "따뜻한 노랑과 갈색", "필름감 있는 포인트"],
+ goodFor: ["브랜드 굿즈", "음악 이벤트", "식음료 브랜드", "콘텐츠 캠페인"],
+ layout: ["포스터식 히어로", "상품 카드 그리드", "굵은 배지", "반복되는 장식 라벨"],
+ sampleType: "retro-commerce",
+ typography: ["둥글거나 기하학적인 제목", "빈티지 포스터 라벨", "두꺼운 가격/숫자"],
+ useCases: ["상품 컬렉션", "이벤트 랜딩", "레코드샵", "레트로 브랜드 페이지"],
+ visual: ["시대감 있는 색감", "인쇄물 질감", "장식적 라벨", "선명한 상품 신호"],
+ },
+ "미래 / 디지털": {
+ caution: "네온과 광택 효과가 많아질수록 정보 대비와 피로도가 나빠질 수 있으므로 본문 영역은 충분히 안정적으로 둬야 합니다.",
+ colorNotes: ["어두운 베이스", "시안과 마젠타", "발광하는 라인", "차가운 보조색"],
+ goodFor: ["AI 서비스", "게임", "개발자 도구", "테크 콘퍼런스"],
+ layout: ["어두운 대시보드", "겹친 패널", "데이터 카드", "광택 있는 경계선"],
+ sampleType: "cyber-dashboard",
+ typography: ["각진 디스플레이 타입", "모노스페이스 메타", "고대비 숫자"],
+ useCases: ["대시보드", "AI 제품 랜딩", "게임 이벤트", "테크 쇼케이스"],
+ visual: ["디지털 광원", "네온 경계선", "깊은 어둠", "미래적 패널"],
+ },
+ "럭셔리 / 클래식": {
+ caution: "고급스러운 분위기를 위해 장식을 늘리기보다 재료감, 간격, 타이포 대비를 절제해서 사용해야 합니다.",
+ colorNotes: ["아이보리와 잉크색", "골드 포인트", "짙은 와인 또는 네이비", "낮은 채도의 보조색"],
+ goodFor: ["하이엔드 커머스", "호텔", "뷰티 브랜드", "프리미엄 포트폴리오"],
+ layout: ["중앙 정렬 히어로", "제품 중심 섹션", "섬세한 경계선", "큰 이미지 영역"],
+ sampleType: "luxury-product",
+ typography: ["세리프풍 제목", "자간이 넓은 라벨", "작은 본문 텍스트"],
+ useCases: ["상품 상세", "호텔 소개", "프리미엄 브랜드", "브랜드 스토리"],
+ visual: ["정제된 장식성", "고전적 비례", "소재감 있는 표면", "차분한 고급감"],
+ },
+ "자연 / 수공예": {
+ caution: "자연스러운 분위기를 만들더라도 버튼, 폼, 가격 같은 기능 정보는 충분히 선명하게 보여야 합니다.",
+ colorNotes: ["식물성 그린", "흙빛 베이지", "점토색 포인트", "부드러운 분홍 또는 크림"],
+ goodFor: ["웰니스", "로컬 브랜드", "수공예 제품", "친환경 서비스"],
+ layout: ["느슨한 카드 묶음", "비정형 색 면", "넓은 행간", "사진 중심 블록"],
+ sampleType: "organic-brand",
+ typography: ["부드러운 산세리프", "손맛 있는 라벨", "편안한 본문 리듬"],
+ useCases: ["브랜드 소개", "제품 컬렉션", "워크숍 페이지", "웰니스 랜딩"],
+ visual: ["자연 질감", "부드러운 색 면", "수작업 감성", "여유로운 리듬"],
+ },
+ "귀여움 / 캐주얼": {
+ caution: "귀여운 요소가 많아질수록 정보의 신뢰도가 낮아질 수 있으므로 구매, 가입, 저장 같은 핵심 행동은 단정하게 유지합니다.",
+ colorNotes: ["밝은 파스텔", "강한 캔디 컬러", "흰 표면", "경쾌한 보조색"],
+ goodFor: ["캐주얼 앱", "이벤트", "키즈 브랜드", "팬 커뮤니티"],
+ layout: ["반복 카드", "큰 배지", "둥근 시각 요소", "짧은 CTA"],
+ sampleType: "kawaii-app",
+ typography: ["둥근 제목", "짧은 라벨", "명랑한 버튼 텍스트"],
+ useCases: ["모바일 앱", "프로모션", "굿즈 페이지", "커뮤니티 이벤트"],
+ visual: ["밝은 에너지", "친근한 형태", "가벼운 움직임", "색상 대비"],
+ },
+ "스트리트 / 서브컬처": {
+ caution: "거친 질감과 강한 색을 쓰더라도 브랜드명, 일정, 구매 같은 핵심 정보가 배경에 묻히지 않아야 합니다.",
+ colorNotes: ["검정과 거친 종이색", "형광색 포인트", "강한 빨강", "스티커 같은 보조색"],
+ goodFor: ["패션", "음악", "스케이트 문화", "서브컬처 이벤트"],
+ layout: ["겹친 포스터", "스티커식 카드", "굵은 테두리", "의도적인 밀도"],
+ sampleType: "street-campaign",
+ typography: ["압축된 굵은 제목", "붙인 듯한 라벨", "거친 메타 텍스트"],
+ useCases: ["룩북", "콘서트 페이지", "드롭 캠페인", "커뮤니티 페이지"],
+ visual: ["거리감 있는 질감", "스티커 레이어", "강한 충돌감", "날것의 그래픽"],
+ },
+ "편집 / 타이포그래피": {
+ caution: "타이포그래피가 주인공이어도 사용자가 빠르게 훑을 수 있도록 목차, 카드, 메타 정보를 체계적으로 배치해야 합니다.",
+ colorNotes: ["종이색 배경", "검정 본문", "편집 포인트 컬러", "사진 또는 콜라주 보조색"],
+ goodFor: ["매거진", "뉴스레터", "아카이브", "전시 소개"],
+ layout: ["엄격한 그리드", "큰 제목", "다단 본문", "인용과 이미지 블록"],
+ sampleType: "magazine-layout",
+ typography: ["큰 헤드라인", "섬세한 본문", "번호와 캡션"],
+ useCases: ["기사 상세", "매거진 홈", "리포트", "콘텐츠 아카이브"],
+ visual: ["글 중심 화면", "편집적 리듬", "그리드 질서", "강한 제목 위계"],
+ },
+ "UI / 웹": {
+ caution: "트렌디한 UI 효과가 기능을 가리지 않도록 상태, 포커스, 비활성, 오류 표현을 함께 설계해야 합니다.",
+ colorNotes: ["제품형 중성 배경", "명확한 브랜드 포인트", "상태 색상", "높은 대비의 텍스트"],
+ goodFor: ["SaaS", "웹 앱", "대시보드", "스타트업 랜딩"],
+ layout: ["컴포넌트 카드", "기능 섹션", "반응형 그리드", "명확한 CTA"],
+ sampleType: "saas-landing",
+ typography: ["실용적인 산세리프", "숫자와 상태 라벨", "명확한 버튼 텍스트"],
+ useCases: ["제품 랜딩", "관리 도구", "앱 소개", "기능 비교"],
+ visual: ["사용성 중심", "선명한 컴포넌트", "상태 표현", "반복 가능한 시스템"],
+ },
+};
+
+const palettes: Record = {
+ minimalism: {
+ base: "#F4F1EA",
+ surface: "#FFFFFF",
+ text: "#202020",
+ mutedText: "#6C675F",
+ primary: "#202020",
+ accent: "#A87854",
+ accent2: "#D8C7AA",
+ accent3: "#9DB0A5",
+ border: "#302B25",
+ },
+ brutalism: {
+ base: "#E4E2DD",
+ surface: "#F0EEE8",
+ text: "#1E1E1E",
+ mutedText: "#6D6962",
+ primary: "#1E1E1E",
+ accent: "#DB4A2B",
+ accent2: "#F8A348",
+ accent3: "#FF89A9",
+ border: "#1E1E1E",
+ },
+ cyberpunk: {
+ base: "#080A14",
+ surface: "#111628",
+ text: "#F5F7FF",
+ mutedText: "#A4A9C7",
+ primary: "#F5F7FF",
+ accent: "#00E5FF",
+ accent2: "#FF3BF4",
+ accent3: "#B7FF3C",
+ border: "#6DEBFF",
+ },
+ luxury: {
+ base: "#F4EFE5",
+ surface: "#FFF9EE",
+ text: "#1D1A16",
+ mutedText: "#746B5C",
+ primary: "#17130F",
+ accent: "#A77A2D",
+ accent2: "#D8B66A",
+ accent3: "#4D1F24",
+ border: "#2B241A",
+ },
+ "organic-design": {
+ base: "#E8E5D8",
+ surface: "#F7F2E4",
+ text: "#263126",
+ mutedText: "#68715E",
+ primary: "#263126",
+ accent: "#6F8B52",
+ accent2: "#C89B63",
+ accent3: "#D7A6A0",
+ border: "#3A4536",
+ },
+ kawaii: {
+ base: "#FFF4F8",
+ surface: "#FFFFFF",
+ text: "#262132",
+ mutedText: "#7A7288",
+ primary: "#262132",
+ accent: "#FF6FAE",
+ accent2: "#70D6FF",
+ accent3: "#FFE66D",
+ border: "#262132",
+ },
+ streetwear: {
+ base: "#EDE9DC",
+ surface: "#F8F3E5",
+ text: "#111111",
+ mutedText: "#5E5A51",
+ primary: "#111111",
+ accent: "#F23827",
+ accent2: "#C7FF00",
+ accent3: "#2D5BFF",
+ border: "#111111",
+ },
+ "editorial-design": {
+ base: "#F3F2EF",
+ surface: "#FFFFFF",
+ text: "#111111",
+ mutedText: "#686868",
+ primary: "#111111",
+ accent: "#E7382F",
+ accent2: "#246BFE",
+ accent3: "#F2D44E",
+ border: "#111111",
+ },
+ glassmorphism: {
+ base: "#EAF1F4",
+ surface: "#F8FCFF",
+ text: "#102027",
+ mutedText: "#60757D",
+ primary: "#102027",
+ accent: "#14B8A6",
+ accent2: "#38BDF8",
+ accent3: "#A78BFA",
+ border: "#17333D",
+ },
+};
+
+const paletteBank: DesignStylePalette[] = [
+ palettes.minimalism,
+ palettes.brutalism,
+ palettes.cyberpunk,
+ palettes.luxury,
+ palettes["organic-design"],
+ palettes.kawaii,
+ palettes.streetwear,
+ palettes["editorial-design"],
+ palettes.glassmorphism,
+ {
+ base: "#F5E7D1",
+ surface: "#FFF3DD",
+ text: "#2A1F19",
+ mutedText: "#7A6251",
+ primary: "#2A1F19",
+ accent: "#D35B2A",
+ accent2: "#F2B84B",
+ accent3: "#2C7A7B",
+ border: "#2A1F19",
+ },
+ {
+ base: "#ECE6F2",
+ surface: "#FBF8FF",
+ text: "#211B2F",
+ mutedText: "#71657F",
+ primary: "#211B2F",
+ accent: "#8F4DFF",
+ accent2: "#FF7AB6",
+ accent3: "#54D6C7",
+ border: "#211B2F",
+ },
+ {
+ base: "#111111",
+ surface: "#1E1E1E",
+ text: "#F6F1E8",
+ mutedText: "#BDB4A4",
+ primary: "#F6F1E8",
+ accent: "#F7D44A",
+ accent2: "#F05A28",
+ accent3: "#7BE0AD",
+ border: "#F6F1E8",
+ },
+];
+
+const styleSeedTuples: DesignStyleSeedTuple[] = [
+ ["minimalism", "미니멀리즘", "Minimalism", "모던 / 미니멀", "가장 적은 요소로 정보의 본질을 드러내는 절제된 디자인", ["minimal", "clean", "essential"], "minimal-editorial"],
+ ["modernism", "모더니즘", "Modernism", "모던 / 미니멀", "기능과 합리성을 우선하는 현대적 조형 언어", ["modern", "functional", "rational"], "minimal-editorial"],
+ ["swiss-design", "스위스 디자인", "Swiss Design", "모던 / 미니멀", "그리드와 타이포그래피 질서를 통해 높은 가독성을 만드는 디자인", ["swiss", "grid", "typography"], "magazine-layout"],
+ ["international-style", "인터내셔널 스타일", "International Style", "모던 / 미니멀", "지역성보다 보편성과 명확성을 강조하는 국제적 시각 체계", ["international", "system", "neutral"], "minimal-editorial"],
+ ["scandinavian", "스칸디나비안", "Scandinavian", "모던 / 미니멀", "밝은 중성색과 생활감 있는 온기를 결합한 북유럽식 미니멀", ["scandinavian", "warm", "lifestyle"], "organic-brand"],
+ ["japandi", "재팬디", "Japandi", "모던 / 미니멀", "일본식 여백과 북유럽의 실용성을 합친 차분한 스타일", ["japandi", "calm", "wood"], "minimal-editorial"],
+ ["warm-minimal", "웜 미니멀", "Warm Minimal", "모던 / 미니멀", "부드러운 중성색과 여백으로 친근한 고급감을 만드는 미니멀", ["warm", "minimal", "soft"], "minimal-editorial"],
+ ["soft-minimal", "소프트 미니멀", "Soft Minimal", "모던 / 미니멀", "낮은 대비와 둥근 리듬으로 긴장을 줄인 미니멀 스타일", ["soft", "gentle", "minimal"], "minimal-editorial"],
+ ["high-end-minimal", "하이엔드 미니멀", "High-End Minimal", "모던 / 미니멀", "고급 소재감과 정교한 간격으로 프리미엄 인상을 만드는 미니멀", ["premium", "minimal", "luxury"], "luxury-product"],
+
+ ["brutalism", "브루탈리즘", "Brutalism", "강렬 / 실험", "거친 구조와 강한 타이포그래피로 날것의 에너지를 보여주는 디자인", ["brutalist", "raw", "poster"], "brutalist-poster"],
+ ["new-brutalism", "뉴 브루탈리즘", "New Brutalism", "강렬 / 실험", "웹 UI의 기본 요소를 과감하고 직접적으로 드러내는 디지털 브루탈리즘", ["new-brutalism", "web", "raw"], "brutalist-poster"],
+ ["anti-design", "안티디자인", "Anti-Design", "강렬 / 실험", "관습적 미감과 균형을 일부러 비틀어 강한 인상을 만드는 스타일", ["anti-design", "experimental", "chaotic"], "brutalist-poster"],
+ ["maximalism", "맥시멀리즘", "Maximalism", "강렬 / 실험", "색, 패턴, 이미지, 타이포를 풍부하게 쌓아 몰입감을 만드는 스타일", ["maximal", "dense", "expressive"], "street-campaign"],
+ ["glitch-art", "글리치 아트", "Glitch Art", "강렬 / 실험", "디지털 오류와 왜곡을 시각 언어로 활용하는 실험적 스타일", ["glitch", "digital", "distortion"], "cyber-dashboard"],
+ ["deconstructivism", "디컨스트럭티비즘", "Deconstructivism", "강렬 / 실험", "구조를 해체하고 재조합해 긴장감 있는 화면을 만드는 디자인", ["deconstructive", "fragmented", "experimental"], "brutalist-poster"],
+ ["avant-garde", "아방가르드", "Avant-Garde", "강렬 / 실험", "기존 질서보다 새로운 조형 실험을 앞세우는 전위적 스타일", ["avant-garde", "art", "experimental"], "magazine-layout"],
+ ["postmodernism", "포스트모더니즘", "Postmodernism", "강렬 / 실험", "다양한 시대와 기호를 섞어 규칙을 유연하게 비트는 스타일", ["postmodern", "mixed", "ironic"], "retro-commerce"],
+
+ ["retro", "레트로", "Retro", "레트로 / 빈티지", "과거의 색감과 그래픽 언어를 현대적으로 다시 쓰는 스타일", ["retro", "nostalgia", "color"], "retro-commerce"],
+ ["vintage", "빈티지", "Vintage", "레트로 / 빈티지", "오래된 인쇄물과 물성에서 오는 시간감을 활용하는 디자인", ["vintage", "print", "aged"], "retro-commerce"],
+ ["seventies-retro", "70년대 레트로", "70s Retro", "레트로 / 빈티지", "따뜻한 오렌지와 곡선 타이포로 70년대 감성을 재현하는 스타일", ["70s", "warm", "groovy"], "retro-commerce"],
+ ["eighties-retro", "80년대 레트로", "80s Retro", "레트로 / 빈티지", "네온, 그리드, 신스웨이브 색으로 80년대 분위기를 만드는 스타일", ["80s", "neon", "synth"], "cyber-dashboard"],
+ ["nineties-graphic", "90년대 그래픽", "90s Graphic", "레트로 / 빈티지", "강한 패턴과 초기 디지털 그래픽 감각을 활용하는 스타일", ["90s", "graphic", "pattern"], "street-campaign"],
+ ["y2k", "Y2K", "Y2K", "레트로 / 빈티지", "크롬, 젤리색, 미래 낙관주의를 섞은 2000년대 초반 감성", ["y2k", "chrome", "bubble"], "cyber-dashboard"],
+ ["retro-futurism", "레트로 퓨처리즘", "Retro Futurism", "레트로 / 빈티지", "과거가 상상한 미래를 현대적 화면으로 재해석하는 스타일", ["retro-future", "space", "optimistic"], "retro-commerce"],
+ ["mid-century-modern", "미드센추리 모던", "Mid-Century Modern", "레트로 / 빈티지", "기하학과 따뜻한 색으로 세련된 20세기 중반 감각을 만드는 스타일", ["mid-century", "geometric", "warm"], "minimal-editorial"],
+ ["bauhaus", "바우하우스", "Bauhaus", "레트로 / 빈티지", "기본 도형과 원색, 기능주의를 결합한 조형 중심 디자인", ["bauhaus", "geometry", "primary"], "magazine-layout"],
+
+ ["futurism", "퓨처리즘", "Futurism", "미래 / 디지털", "속도감과 기술 낙관주의를 시각화하는 미래지향적 스타일", ["future", "speed", "tech"], "cyber-dashboard"],
+ ["cyberpunk", "사이버펑크", "Cyberpunk", "미래 / 디지털", "어두운 도시, 네온, 디지털 반항성을 결합한 강한 테크 스타일", ["cyberpunk", "neon", "dark"], "cyber-dashboard"],
+ ["neon-noir", "네온 누아르", "Neon Noir", "미래 / 디지털", "느와르의 어둠과 네온 광원을 결합한 분위기 중심 스타일", ["neon", "noir", "dark"], "cyber-dashboard"],
+ ["techwear", "테크웨어", "Techwear", "미래 / 디지털", "기능성 의류와 산업적 디테일을 웹 그래픽으로 옮긴 스타일", ["techwear", "utility", "dark"], "cyber-dashboard"],
+ ["high-tech", "하이테크", "High-Tech", "미래 / 디지털", "정밀한 패널과 데이터 시각화로 첨단 이미지를 만드는 스타일", ["high-tech", "interface", "data"], "saas-landing"],
+ ["ai-aesthetic", "AI 에스테틱", "AI Aesthetic", "미래 / 디지털", "생성형 이미지, 빛나는 그라데이션, 계산적 패턴을 결합한 스타일", ["ai", "generated", "gradient"], "saas-landing"],
+ ["hologram-style", "홀로그램 스타일", "Hologram Style", "미래 / 디지털", "반투명 빛과 분광 색으로 입체적 디지털 감각을 만드는 스타일", ["hologram", "iridescent", "future"], "cyber-dashboard"],
+ ["chromecore", "크롬코어", "Chromecore", "미래 / 디지털", "금속성 광택과 반사 질감을 중심으로 한 미래적 스타일", ["chrome", "metal", "gloss"], "luxury-product"],
+ ["metaverse-style", "메타버스 스타일", "Metaverse Style", "미래 / 디지털", "가상 공간, 아바타, 3D 인터페이스 감각을 보여주는 스타일", ["metaverse", "virtual", "3d"], "cyber-dashboard"],
+
+ ["classic", "클래식", "Classic", "럭셔리 / 클래식", "균형 잡힌 비례와 전통적 타이포그래피로 안정감을 주는 스타일", ["classic", "balanced", "timeless"], "luxury-product"],
+ ["neoclassic", "네오클래식", "Neoclassic", "럭셔리 / 클래식", "고전적 장식과 현대적 여백을 절충한 우아한 스타일", ["neoclassic", "elegant", "modern"], "luxury-product"],
+ ["luxury", "럭셔리", "Luxury", "럭셔리 / 클래식", "절제된 소재감과 고급 색감으로 프리미엄 이미지를 만드는 스타일", ["luxury", "premium", "gold"], "luxury-product"],
+ ["old-money", "올드머니", "Old Money", "럭셔리 / 클래식", "과시보다 전통과 품질을 강조하는 조용한 고급 스타일", ["old-money", "heritage", "quiet"], "luxury-product"],
+ ["art-deco", "아르데코", "Art Deco", "럭셔리 / 클래식", "기하학 장식과 금속 포인트로 화려한 질서를 만드는 스타일", ["art-deco", "geometry", "gold"], "luxury-product"],
+ ["art-nouveau", "아르누보", "Art Nouveau", "럭셔리 / 클래식", "식물적 곡선과 장식적 선으로 우아한 흐름을 만드는 스타일", ["art-nouveau", "ornamental", "curve"], "organic-brand"],
+ ["baroque", "바로크", "Baroque", "럭셔리 / 클래식", "극적인 장식과 명암으로 풍부한 감정을 만드는 고전 스타일", ["baroque", "dramatic", "ornate"], "luxury-product"],
+ ["rococo", "로코코", "Rococo", "럭셔리 / 클래식", "부드러운 장식과 밝은 색으로 섬세하고 화려한 분위기를 만드는 스타일", ["rococo", "delicate", "ornate"], "luxury-product"],
+ ["gothic", "고딕", "Gothic", "럭셔리 / 클래식", "뾰족한 구조와 어두운 분위기로 장중함을 만드는 스타일", ["gothic", "dark", "dramatic"], "street-campaign"],
+
+ ["organic-design", "오가닉 디자인", "Organic Design", "자연 / 수공예", "자연 형태와 유기적 색감으로 편안한 인상을 만드는 디자인", ["organic", "natural", "soft"], "organic-brand"],
+ ["natural", "내추럴", "Natural", "자연 / 수공예", "꾸미지 않은 색과 소재감으로 신뢰와 편안함을 만드는 스타일", ["natural", "plain", "calm"], "organic-brand"],
+ ["botanical", "보태니컬", "Botanical", "자연 / 수공예", "식물 모티프와 그린 팔레트를 중심으로 한 생기 있는 스타일", ["botanical", "green", "plant"], "organic-brand"],
+ ["eco-design", "에코 디자인", "Eco Design", "자연 / 수공예", "지속가능성과 친환경 메시지를 명확히 전달하는 디자인", ["eco", "sustainable", "green"], "organic-brand"],
+ ["rustic", "러스틱", "Rustic", "자연 / 수공예", "거친 나무와 흙빛 질감으로 소박한 온기를 만드는 스타일", ["rustic", "wood", "earth"], "organic-brand"],
+ ["kinfolk", "킨포크", "Kinfolk", "자연 / 수공예", "느린 생활, 자연광, 절제된 구성을 강조하는 라이프스타일 스타일", ["kinfolk", "lifestyle", "slow"], "minimal-editorial"],
+ ["handmade", "핸드메이드", "Handmade", "자연 / 수공예", "손으로 만든 흔적과 작은 불균형을 매력으로 쓰는 스타일", ["handmade", "craft", "human"], "organic-brand"],
+ ["craft", "크래프트", "Craft", "자연 / 수공예", "재료와 제작 과정을 전면에 드러내는 수공예 중심 스타일", ["craft", "material", "maker"], "organic-brand"],
+ ["wabi-sabi", "와비사비", "Wabi-Sabi", "자연 / 수공예", "불완전함과 시간의 흔적을 아름다움으로 받아들이는 스타일", ["wabi-sabi", "imperfect", "quiet"], "minimal-editorial"],
+
+ ["kitsch", "키치", "Kitsch", "귀여움 / 캐주얼", "과장되고 통속적인 요소를 즐겁게 활용하는 스타일", ["kitsch", "fun", "bold"], "kawaii-app"],
+ ["kawaii", "카와이", "Kawaii", "귀여움 / 캐주얼", "작고 둥근 형태와 밝은 색으로 귀여움을 극대화하는 스타일", ["kawaii", "cute", "pastel"], "kawaii-app"],
+ ["dopamine-design", "도파민 디자인", "Dopamine Design", "귀여움 / 캐주얼", "강한 색과 즉각적 즐거움으로 긍정적 반응을 만드는 스타일", ["dopamine", "bright", "joy"], "kawaii-app"],
+ ["pop-art", "팝아트", "Pop Art", "귀여움 / 캐주얼", "대중문화 이미지와 선명한 색을 그래픽하게 활용하는 스타일", ["pop-art", "bold", "comic"], "street-campaign"],
+ ["comic-book-style", "코믹북 스타일", "Comic Book Style", "귀여움 / 캐주얼", "말풍선, 굵은 선, 컷 구성을 활용한 만화적 스타일", ["comic", "panel", "bold"], "kawaii-app"],
+ ["toy-design", "토이 디자인", "Toy Design", "귀여움 / 캐주얼", "장난감 같은 형태와 촉감 있는 색으로 즐거움을 만드는 스타일", ["toy", "play", "soft"], "kawaii-app"],
+ ["playful-design", "플레이풀 디자인", "Playful Design", "귀여움 / 캐주얼", "가벼운 상호작용과 유쾌한 형태로 접근성을 높이는 스타일", ["playful", "friendly", "motion"], "kawaii-app"],
+ ["pastel-style", "파스텔 스타일", "Pastel Style", "귀여움 / 캐주얼", "낮은 채도의 밝은 색으로 부드럽고 친근한 화면을 만드는 스타일", ["pastel", "soft", "friendly"], "kawaii-app"],
+ ["bubble-design", "버블 디자인", "Bubble Design", "귀여움 / 캐주얼", "둥근 볼륨과 말랑한 색 면으로 가벼운 즐거움을 주는 스타일", ["bubble", "round", "soft"], "kawaii-app"],
+
+ ["streetwear", "스트리트웨어", "Streetwear", "스트리트 / 서브컬처", "패션 드롭과 거리 문화를 웹 그래픽으로 옮긴 스타일", ["streetwear", "drop", "fashion"], "street-campaign"],
+ ["graffiti", "그래피티", "Graffiti", "스트리트 / 서브컬처", "스프레이, 태그, 벽면 질감을 활용하는 거리 그래픽 스타일", ["graffiti", "spray", "tag"], "street-campaign"],
+ ["hiphop-style", "힙합 스타일", "Hip-Hop Style", "스트리트 / 서브컬처", "리듬, 자신감, 강한 그래픽 언어를 보여주는 음악 문화 스타일", ["hiphop", "music", "bold"], "street-campaign"],
+ ["skate-culture", "스케이트 문화", "Skate Culture", "스트리트 / 서브컬처", "스티커, 보드 그래픽, 거친 촬영감을 활용하는 스타일", ["skate", "sticker", "youth"], "street-campaign"],
+ ["punk", "펑크", "Punk", "스트리트 / 서브컬처", "저항적 메시지와 찢긴 그래픽을 앞세우는 반항적 스타일", ["punk", "rebellious", "raw"], "street-campaign"],
+ ["grunge", "그런지", "Grunge", "스트리트 / 서브컬처", "낡고 거친 질감, 흐릿한 이미지, 어두운 색을 활용하는 스타일", ["grunge", "dirty", "texture"], "street-campaign"],
+ ["indie-sleaze", "인디 슬리즈", "Indie Sleaze", "스트리트 / 서브컬처", "플래시 사진과 지저분한 클럽 감성을 섞은 서브컬처 스타일", ["indie", "sleaze", "flash"], "street-campaign"],
+ ["rave-style", "레이브 스타일", "Rave Style", "스트리트 / 서브컬처", "강한 형광색과 음악 이벤트 에너지를 보여주는 스타일", ["rave", "neon", "music"], "cyber-dashboard"],
+ ["lo-fi", "로우파이", "Lo-Fi", "스트리트 / 서브컬처", "낮은 해상도와 아날로그 노이즈를 편안한 분위기로 쓰는 스타일", ["lo-fi", "analog", "calm"], "retro-commerce"],
+
+ ["typography-focused", "타이포그래피 중심", "Typography Focused", "편집 / 타이포그래피", "글자의 크기, 무게, 리듬을 핵심 시각 요소로 삼는 스타일", ["typography", "type", "text"], "magazine-layout"],
+ ["editorial-design", "에디토리얼 디자인", "Editorial Design", "편집 / 타이포그래피", "기사, 이미지, 인용을 편집물처럼 조직하는 스타일", ["editorial", "magazine", "grid"], "magazine-layout"],
+ ["magazine-style", "매거진 스타일", "Magazine Style", "편집 / 타이포그래피", "커버, 기사, 목차의 리듬을 웹 화면으로 옮긴 스타일", ["magazine", "cover", "article"], "magazine-layout"],
+ ["posterism", "포스터리즘", "Posterism", "편집 / 타이포그래피", "한 장의 포스터처럼 강한 메시지와 시각 집중을 만드는 스타일", ["poster", "campaign", "bold"], "brutalist-poster"],
+ ["grid-system", "그리드 시스템", "Grid System", "편집 / 타이포그래피", "정보를 엄격한 칸과 비례로 정렬하는 구조 중심 스타일", ["grid", "system", "layout"], "magazine-layout"],
+ ["collage", "콜라주", "Collage", "편집 / 타이포그래피", "사진, 종이, 타이포 조각을 겹쳐 새로운 맥락을 만드는 스타일", ["collage", "mixed", "paper"], "magazine-layout"],
+ ["photomontage", "포토몽타주", "Photomontage", "편집 / 타이포그래피", "서로 다른 이미지를 결합해 강한 메시지를 만드는 편집 스타일", ["photomontage", "photo", "editorial"], "magazine-layout"],
+ ["experimental-type", "실험 타이포", "Experimental Type", "편집 / 타이포그래피", "글자의 형태 자체를 실험해 이미지처럼 쓰는 스타일", ["experimental-type", "type", "art"], "brutalist-poster"],
+ ["newspaper-style", "신문 스타일", "Newspaper Style", "편집 / 타이포그래피", "다단 편집과 헤드라인 구조를 활용하는 뉴스형 스타일", ["newspaper", "columns", "news"], "magazine-layout"],
+
+ ["flat-design", "플랫 디자인", "Flat Design", "UI / 웹", "입체 효과를 줄이고 명확한 색 면과 아이콘으로 구성하는 UI 스타일", ["flat", "ui", "simple"], "saas-landing"],
+ ["material-design", "머티리얼 디자인", "Material Design", "UI / 웹", "표면, 그림자, 상태 변화를 체계화한 제품형 UI 스타일", ["material", "ui", "system"], "saas-landing"],
+ ["neumorphism", "뉴모피즘", "Neumorphism", "UI / 웹", "부드러운 안팎 그림자로 눌린 표면감을 만드는 UI 스타일", ["neumorphism", "soft-ui", "surface"], "saas-landing"],
+ ["glassmorphism", "글래스모피즘", "Glassmorphism", "UI / 웹", "반투명 유리 패널과 흐림 효과를 중심으로 한 UI 스타일", ["glass", "translucent", "ui"], "saas-landing"],
+ ["claymorphism", "클레이모피즘", "Claymorphism", "UI / 웹", "말랑한 3D 카드와 밝은 색으로 친근한 제품 UI를 만드는 스타일", ["clay", "3d", "soft"], "kawaii-app"],
+ ["dark-mode-design", "다크모드 디자인", "Dark Mode Design", "UI / 웹", "어두운 배경에서 정보 대비와 피로도를 관리하는 UI 스타일", ["dark-mode", "ui", "contrast"], "cyber-dashboard"],
+ ["saas-style", "SaaS 스타일", "SaaS Style", "UI / 웹", "기능, 신뢰, 전환을 명확히 보여주는 제품형 웹 스타일", ["saas", "product", "landing"], "saas-landing"],
+ ["startup-landing-page", "스타트업 랜딩페이지", "Startup Landing Page", "UI / 웹", "빠른 가치 전달과 CTA 반복으로 전환을 유도하는 웹 스타일", ["startup", "landing", "conversion"], "saas-landing"],
+];
+
+const styleSeeds: DesignStyleSeed[] = styleSeedTuples.map(([slug, nameKo, nameEn, category, tone, tags, sampleType]) => ({
+ category,
+ nameEn,
+ nameKo,
+ palette: palettes[slug] ?? paletteBank[Math.abs(hashSlug(slug)) % paletteBank.length],
+ sampleType,
+ slug,
+ tags,
+ tone,
+}));
+
+function hashSlug(slug: string) {
+ return slug.split("").reduce((hash, char) => hash + char.charCodeAt(0), 0);
+}
+
+function buildStyle(seed: DesignStyleSeed, index: number): DesignStyle {
+ const profile = categoryProfiles[seed.category];
+ const categoryPeers = styleSeeds.filter((style) => style.category === seed.category);
+ const peerIndex = categoryPeers.findIndex((style) => style.slug === seed.slug);
+ const related = [
+ categoryPeers[(peerIndex + 1) % categoryPeers.length]?.slug,
+ categoryPeers[(peerIndex + 2) % categoryPeers.length]?.slug,
+ styleSeeds[(index + 11) % styleSeeds.length]?.slug,
+ ].filter((slug): slug is string => Boolean(slug) && slug !== seed.slug);
+
+ return {
+ slug: seed.slug,
+ nameKo: seed.nameKo,
+ nameEn: seed.nameEn,
+ category: seed.category,
+ summary: `${seed.nameKo}은 ${seed.tone}입니다.`,
+ description: `${seed.nameKo}은 ${seed.tone}입니다. ${seed.category} 범주 안에서 ${profile.visual[0]}과 ${profile.visual[1]}을 중심으로 화면의 첫인상을 만듭니다. 웹페이지에서는 ${profile.layout[0]}, ${profile.layout[1]}, ${profile.layout[2]}를 조합해 사용자가 분위기와 정보 구조를 동시에 이해하도록 돕습니다. ${profile.caution}`,
+ visualFeatures: [
+ `${profile.visual[0]}을 핵심 신호로 사용합니다.`,
+ `${profile.visual[1]}과 ${profile.visual[2]}을 함께 배치합니다.`,
+ `${seed.nameKo} 특유의 ${seed.tags[0]} 분위기를 명확히 드러냅니다.`,
+ `${profile.visual[3]}을 통해 카드와 상세 페이지의 차이를 만듭니다.`,
+ ],
+ colorPalette: [
+ `${profile.colorNotes[0]}을 기본 배경으로 사용합니다.`,
+ `${profile.colorNotes[1]}을 보조 표면에 배치합니다.`,
+ `${profile.colorNotes[2]}으로 본문 대비를 확보합니다.`,
+ `${profile.colorNotes[3]}은 CTA와 강조 정보에만 제한적으로 사용합니다.`,
+ ],
+ typography: [
+ profile.typography[0],
+ profile.typography[1],
+ profile.typography[2],
+ ],
+ layoutTraits: [
+ profile.layout[0],
+ profile.layout[1],
+ profile.layout[2],
+ profile.layout[3],
+ ],
+ useCases: [...profile.useCases],
+ goodFor: [...profile.goodFor],
+ cautions: [
+ profile.caution,
+ "선택한 스타일이 콘텐츠의 실제 목적보다 앞서지 않도록 핵심 정보 위계를 먼저 고정해야 합니다.",
+ "모바일에서는 장식 요소를 줄이고 제목, 본문, CTA의 가독성을 우선 확인해야 합니다.",
+ ],
+ tags: [...new Set([...seed.tags, seed.category.split(" / ")[0], seed.sampleType ?? profile.sampleType])],
+ related: related.slice(0, 3),
+ palette: seed.palette,
+ imagePrompt: `A square editorial poster representing ${seed.nameEn} design style, ${seed.tone}, ${profile.visual.join(", ")}, ${promptSuffix}`,
+ sampleType: seed.sampleType ?? profile.sampleType,
+ };
+}
+
+export const defaultDesignStyleSlug = "brutalism";
+
+export const designStyles: DesignStyle[] = styleSeeds.map(buildStyle);
+
+export const designStyleCategories = [...new Set(designStyles.map((style) => style.category))];
+
+export const designStyleTags = [...new Set(designStyles.flatMap((style) => style.tags))].sort();
+
+export function getDesignStyleBySlug(slug: string) {
+ return designStyles.find((style) => style.slug === slug);
+}
+
+export function getRelatedDesignStyles(slugs: string[]) {
+ return slugs
+ .map((slug) => getDesignStyleBySlug(slug))
+ .filter((style): style is DesignStyle => Boolean(style));
+}
diff --git a/src/lib/paletteGenerator.ts b/src/lib/paletteGenerator.ts
new file mode 100644
index 0000000..2829515
--- /dev/null
+++ b/src/lib/paletteGenerator.ts
@@ -0,0 +1,155 @@
+import type { DesignStyle, DesignStylePalette } from "@/data/designStyles";
+
+const darkPattern = /dark|cyber|neon|noir|black|midnight|다크|네온|사이버|누아르|검정|어두/i;
+const softPattern = /soft|pastel|warm|minimal|calm|gentle|파스텔|소프트|따뜻|미니멀|차분/i;
+const luxuryPattern = /luxury|classic|gold|premium|old money|럭셔리|클래식|골드|프리미엄|올드머니/i;
+const naturalPattern = /organic|nature|botanical|eco|craft|natural|오가닉|자연|보태니컬|에코|수공예|크래프트/i;
+
+function hashText(input: string) {
+ let hash = 2166136261;
+
+ for (let index = 0; index < input.length; index += 1) {
+ hash ^= input.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+
+ return hash >>> 0;
+}
+
+function hslToHex(hue: number, saturation: number, lightness: number) {
+ const normalizedHue = (((hue % 360) + 360) % 360) / 360;
+ const normalizedSaturation = saturation / 100;
+ const normalizedLightness = lightness / 100;
+
+ const hueToRgb = (p: number, q: number, t: number) => {
+ let value = t;
+ if (value < 0) value += 1;
+ if (value > 1) value -= 1;
+ if (value < 1 / 6) return p + (q - p) * 6 * value;
+ if (value < 1 / 2) return q;
+ if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
+ return p;
+ };
+
+ let red: number;
+ let green: number;
+ let blue: number;
+
+ if (normalizedSaturation === 0) {
+ red = normalizedLightness;
+ green = normalizedLightness;
+ blue = normalizedLightness;
+ } else {
+ const q =
+ normalizedLightness < 0.5
+ ? normalizedLightness * (1 + normalizedSaturation)
+ : normalizedLightness + normalizedSaturation - normalizedLightness * normalizedSaturation;
+ const p = 2 * normalizedLightness - q;
+ red = hueToRgb(p, q, normalizedHue + 1 / 3);
+ green = hueToRgb(p, q, normalizedHue);
+ blue = hueToRgb(p, q, normalizedHue - 1 / 3);
+ }
+
+ return [red, green, blue]
+ .map((channel) =>
+ Math.round(channel * 255)
+ .toString(16)
+ .padStart(2, "0"),
+ )
+ .join("")
+ .toUpperCase()
+ .padStart(6, "0")
+ .replace(/^/, "#");
+}
+
+function readableMuted(text: string, dark: boolean) {
+ return dark ? hslToHex(hashText(text) % 360, 14, 68) : hslToHex(hashText(text) % 360, 12, 38);
+}
+
+export function createPromptStylePreset(prompt: string, basePreset: DesignStyle): DesignStyle {
+ const seedText = `${basePreset.slug}:${prompt.trim() || basePreset.imagePrompt}`;
+ const seed = hashText(seedText);
+ const hue = seed % 360;
+ const secondHue = hue + 34 + (seed % 28);
+ const thirdHue = hue + 176 + (seed % 42);
+ const isDark = darkPattern.test(seedText) || basePreset.palette.base.toLowerCase() === "#080a14";
+ const isSoft = softPattern.test(seedText);
+ const isLuxury = luxuryPattern.test(seedText);
+ const isNatural = naturalPattern.test(seedText);
+
+ let palette: DesignStylePalette;
+
+ if (isLuxury) {
+ palette = {
+ base: "#F3EBDD",
+ surface: "#FFF8EA",
+ text: "#17120D",
+ mutedText: "#706553",
+ primary: "#17120D",
+ accent: hslToHex(40 + (seed % 18), 58, 43),
+ accent2: hslToHex(44 + (seed % 14), 68, 66),
+ accent3: hslToHex(350 + (seed % 18), 38, 30),
+ border: "#2A2118",
+ };
+ } else if (isNatural) {
+ palette = {
+ base: hslToHex(70 + (seed % 34), 23, 86),
+ surface: hslToHex(58 + (seed % 24), 36, 94),
+ text: "#243024",
+ mutedText: "#66715E",
+ primary: "#243024",
+ accent: hslToHex(92 + (seed % 38), 36, 42),
+ accent2: hslToHex(28 + (seed % 24), 48, 62),
+ accent3: hslToHex(350 + (seed % 30), 38, 68),
+ border: "#35422F",
+ };
+ } else if (isDark) {
+ palette = {
+ base: hslToHex(hue, 34, 7),
+ surface: hslToHex(hue + 8, 32, 13),
+ text: "#F6F7FB",
+ mutedText: readableMuted(seedText, true),
+ primary: "#F6F7FB",
+ accent: hslToHex(hue, 92, 58),
+ accent2: hslToHex(secondHue, 88, 60),
+ accent3: hslToHex(thirdHue, 82, 64),
+ border: hslToHex(hue, 72, 62),
+ };
+ } else if (isSoft) {
+ palette = {
+ base: hslToHex(hue, 28, 91),
+ surface: hslToHex(hue + 8, 38, 97),
+ text: "#28231F",
+ mutedText: readableMuted(seedText, false),
+ primary: "#28231F",
+ accent: hslToHex(hue, 52, 56),
+ accent2: hslToHex(secondHue, 48, 68),
+ accent3: hslToHex(thirdHue, 50, 76),
+ border: "#342E28",
+ };
+ } else {
+ palette = {
+ base: hslToHex(hue, 24, 88),
+ surface: hslToHex(hue + 10, 34, 95),
+ text: "#161616",
+ mutedText: readableMuted(seedText, false),
+ primary: "#161616",
+ accent: hslToHex(hue, 78, 48),
+ accent2: hslToHex(secondHue, 72, 58),
+ accent3: hslToHex(thirdHue, 68, 68),
+ border: "#161616",
+ };
+ }
+
+ return {
+ ...basePreset,
+ slug: "custom-prompt",
+ nameKo: "프롬프트 팔레트",
+ nameEn: "Prompt Palette",
+ category: "Custom",
+ summary: prompt.trim() || basePreset.summary,
+ tags: [...new Set(["custom", "palette", ...basePreset.tags.slice(0, 3)])],
+ palette,
+ imagePrompt: `${prompt.trim() || basePreset.imagePrompt}, clean composition, high-quality design reference image, no logo, no watermark`,
+ };
+}