diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 7f5bae6..1355a72 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -5,6 +5,84 @@
---
+## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
+
+**왜**
+아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을
+매거진으로 바꿔도 **아이템 섹션만 레트로로 남아** 화면이 두 벌로 보였다. 아이템은 레트로 전용
+부품이 아니라 어느 템플릿에나 들어가는 섹션이다.
+그리고 발행본은 **색만** 템플릿을 따랐다 — `theme` 계약에 생김새(look)가 없어서, 레트로를 골라도
+발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
+
+**한 일**
+- 아이템 1종 추가 — **계절별 추천 하루**(`planner.podium`). 계절 탭 + 1·2·3위 카드.
+ 기존 `schedule` 과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 **시각을 계산한다**.
+ 사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다.
+ 조립 규칙(`planDay`·`plannerTop`·`plannerSeasons`)은 파서와 같은 이유로 `@o2o/shared` 한 벌이다 —
+ 빌더와 발행본이 같은 조건에서 **같은 시각**을 내야 한다.
+ 밤 9시를 넘기는 칸은 넣지 않고 **뺐다고 화면에 밝힌다**(숨기면 사장님은 왜 없는지 모른다).
+- 아이템 색·서체를 전부 템플릿 토큰(`--tpl-*`)으로. `retro/common.tsx` → `items/common.tsx`,
+ `RETRO_*` 상수 → `ITEM_*` 토큰. 글자 단계는 stone-400/500/600 대신 **불투명도**로 만든다 —
+ 팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도 `currentColor` 로 판다.
+- **`SiteTheme.look` 계약 추가** — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다.
+ 프론트가 저장하고(`toThemePayload`), 서버는 해석 없이 싣고(`_theme`), `seo/head.ts` 가 `--tpl-*` 로 심는다.
+ 발행본 `.serif`·`body` 도 이 토큰을 읽는다.
+- 웹폰트는 **템플릿이 쓰는 것만** 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면
+ 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다.
+- 색 유도식(`deriveSurfaces`)을 `@o2o/shared` 로. 캔버스·쇼케이스·**발행본**이 같은 식을 써야
+ 미리보기가 거짓말을 하지 않는다. 프론트 `lib/color.ts` 는 재수출만 남겼다.
+
+**밟은 함정**
+- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 **사라진다**(연한 accent + 밝은 바탕).
+ → `color-mix(accent 70%, currentColor)` — 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다.
+- 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 **글자색**으로 채운다.
+- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다.
+
+**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed.
+쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인.
+실물 프리렌더(레트로 look + planner): `
` 에 `--tpl-font-heading: 'Gugi'…`·`--tpl-border-width: 2px`,
+`family=Gugi&family=Gowun+Batang` 링크, 계절 묶음 여름·가을, 순위 1·2위,
+계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true.
+**옛 payload(look 없음)** 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인.
+백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — `_theme`·`_sections` 는 함수 단위로 직접 확인했다.
+
+## 2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다
+
+**왜**
+아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은
+빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — **아홉 개 전부 발행본에 안 나갔다.**
+`SectionSetting` 계약에 `data` 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다
+(소개문 `body` 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다.
+
+**한 일**
+- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) ·
+ 오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립).
+ `dataSpec` 에 스키마·프롬프트·예시, `registry` 에 배리에이션 한 줄씩.
+ [+ 섹션 추가] 목록은 `dataSpec` 에서 파생돼(addable.ts) 따로 손댈 곳이 없다.
+- **읽는 쪽 계약을 `@o2o/shared` 로 옮겼다**(`lib/section-data.ts`) — 항목 타입 · `parseSectionData`.
+ 같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다.
+ 빌더에는 **쓰는 쪽**(프롬프트·예시·라벨)만 남았다.
+- `SectionSetting.data` 계약 추가 · `site_payload._sections()` 가 그대로 실어 보낸다(서버는 파싱하지 않는다).
+- 발행 사이트에 아이템 섹션 아홉(`site/src/sections/items/`). **인터랙션은 옮기지 않았다** —
+ 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다.
+ 이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다.
+- 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데
+ 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — `intro.body` 와 같은 구멍이다(백엔드 fake 도 같이).
+- 간판체(Gugi)는 **아이템을 실제로 쓰는 사이트에만** `` 로 내려보낸다. 서체 하나가
+ 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다.
+
+**안 한 것**
+레트로 템플릿 시드(`defaultSectionTypes`)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면
+빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거).
+
+**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed.
+실물 프리렌더: 아이템 아홉이 든 payload → `ok=true`, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과
+본문 문장 전부 포함, `family=Gugi` 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음.
+백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 —
+바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다.
+
+---
+
## 2026-09-02 — 회원가입과 구글 로그인
**한 일**
@@ -47,8 +125,6 @@
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
----
-
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜**
diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py
index 4fb6a94..acef4e4 100644
--- a/solution/backend/conftest.py
+++ b/solution/backend/conftest.py
@@ -196,6 +196,15 @@ def fake_renderer(monkeypatch, tmp_path_factory):
# 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다.
MIN_UNIQUE_TEXT = 8
+ def _has_long_text(value, long) -> bool:
+ if isinstance(value, str):
+ return long(value)
+ if isinstance(value, list):
+ return any(_has_long_text(v, long) for v in value)
+ if isinstance(value, dict):
+ return any(_has_long_text(v, long) for v in value.values())
+ return False
+
def _count(payload: dict) -> int:
def long(value) -> bool:
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
@@ -207,6 +216,17 @@ def fake_renderer(monkeypatch, tmp_path_factory):
)
if intro and intro.get("enabled") and long(intro.get("body")):
count += 1
+ # 붙여넣기 아이템(theme.sections[].data)의 항목도 고유 콘텐츠다 — 렌더러와 같은 규칙.
+ for section in (payload.get("theme") or {}).get("sections") or []:
+ if not section.get("enabled") or not section.get("data"):
+ continue
+ try:
+ envelope = json.loads(section["data"])
+ except (ValueError, TypeError):
+ continue
+ for item in (envelope or {}).get("items") or []:
+ if _has_long_text(item, long):
+ count += 1
facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or [])
diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py
index b8de4fd..6574fb1 100644
--- a/solution/backend/router/v1/site/protocol.py
+++ b/solution/backend/router/v1/site/protocol.py
@@ -87,9 +87,9 @@ class Req_SiteTheme(SiteProtocol):
떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
- {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
- sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
- (별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
+ {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
+ sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
+ (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다.
diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py
index f0f8d3a..d34fd59 100644
--- a/solution/backend/router/v1/site/site.py
+++ b/solution/backend/router/v1/site/site.py
@@ -105,8 +105,8 @@ async def set_template(
summary="디자인(색·서체·섹션) 저장",
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
- "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
- "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
+ "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
+ "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. "
diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py
index 44ae29a..22ad95e 100644
--- a/solution/backend/services/site_payload.py
+++ b/solution/backend/services/site_payload.py
@@ -295,7 +295,7 @@ def _theme(site, theme_spec: dict) -> dict:
# ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
# 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
- return {
+ out = {
# 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
# 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
"templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
@@ -303,6 +303,13 @@ def _theme(site, theme_spec: dict) -> dict:
"fontStyle": font_style,
"sections": _sections(saved.get("sections"), theme_spec["sections"]),
}
+ # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 —
+ # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다.
+ # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다.
+ look = saved.get("look")
+ if isinstance(look, dict) and look:
+ out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
+ return out
def _sections(saved_sections, default_spec) -> list:
@@ -372,6 +379,12 @@ def _sections(saved_sections, default_spec) -> list:
body = _text(item.get("body"))
if body:
entry["body"] = body
+ # ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다.
+ # 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다.
+ # 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts).
+ data = _text(item.get("data"))
+ if data:
+ entry["data"] = data
out.append(entry)
for sid, label, locked in default_spec:
diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx
index c8ad7cb..fbb6620 100644
--- a/solution/frontend/src/features/builder/RightTabsPanel.tsx
+++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx
@@ -16,7 +16,7 @@ import {
Wand2,
X,
} from 'lucide-react';
-import type {InfoField} from '@o2o/shared';
+import {parseSectionData, type InfoField} from '@o2o/shared';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
@@ -30,7 +30,6 @@ import {resolveVariant} from './canvas/registry';
import {
buildPrompt,
dataSpecFor,
- parseSectionData,
SECTION_DATA_MAX_CHARS,
} from './canvas/dataSpec';
diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts
index 7b1fd77..39ea8aa 100644
--- a/solution/frontend/src/features/builder/canvas/dataSpec.ts
+++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts
@@ -1,86 +1,17 @@
/**
- * 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처.
+ * 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나".
+ *
+ * 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 —
+ * 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`).
*
* 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다.
*/
-/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */
-export type DataVerified = '확인' | '확인필요';
-
-export interface DataSource {
- name: string;
- url?: string;
-}
-
-export interface SongItem {
- title: string;
- artist?: string;
- lyricist?: string;
- composer?: string;
- year?: number;
- label?: string;
- labelColor?: string;
- story?: string;
- connection?: string;
- verified?: DataVerified;
- source?: DataSource;
-}
-
-export interface DailyItem {
- monthDay: string;
- category?: string;
- title: string;
- body?: string;
- season?: string;
- tags?: string[];
- verified?: DataVerified;
- source?: DataSource;
-}
-
-export interface CourseStop {
- order?: number;
- name: string;
- minutes?: number;
- note?: string;
- searchQuery?: string;
-}
-
-export interface CourseItem {
- name: string;
- duration?: string;
- startsFrom?: string;
- stops?: CourseStop[];
- verified?: DataVerified;
- source?: DataSource;
-}
-
-export interface ScheduleSlot {
- /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
- time: string;
- title: string;
- place?: string;
- minutes?: number;
- note?: string;
- searchQuery?: string;
-}
-
-export interface ScheduleItem {
- name: string;
- /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
- audience?: string;
- season?: string;
- slots?: ScheduleSlot[];
- verified?: DataVerified;
- source?: DataSource;
-}
-
export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
label: string;
- /** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */
- requiredKey: string;
/** [예시 넣기] 가 그대로 넣는 값. */
sample: string;
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
@@ -153,7 +84,6 @@ export const SECTION_DATA_SPEC: Record = {
songs: {
kind: 'songs',
label: '가요 다방',
- requiredKey: 'title',
sample: JSON.stringify(
{
kind: 'songs',
@@ -217,7 +147,6 @@ export const SECTION_DATA_SPEC: Record = {
daily: {
kind: 'daily',
label: '오늘의 한 장',
- requiredKey: 'title',
sample: JSON.stringify(
{
kind: 'daily',
@@ -279,7 +208,6 @@ export const SECTION_DATA_SPEC: Record = {
course: {
kind: 'course',
label: '반나절 산책',
- requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'course',
@@ -326,7 +254,6 @@ export const SECTION_DATA_SPEC: Record = {
schedule: {
kind: 'schedule',
label: '여행 스케줄',
- requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'schedule',
@@ -372,118 +299,396 @@ export const SECTION_DATA_SPEC: Record = {
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
+ people: {
+ kind: 'people',
+ label: '인물 열전',
+ sample: JSON.stringify(
+ {
+ kind: 'people',
+ version: 1,
+ title: '인물 열전',
+ subtitle: '이 도시가 배출한 이름들',
+ items: [
+ {
+ name: '채만식',
+ aka: '백릉',
+ years: '1902–1950',
+ role: '소설가',
+ oneLine: '군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.',
+ imageQuery: '채만식문학관 채만식 초상',
+ verified: '확인',
+ source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
+ },
+ {
+ name: '고은',
+ years: '1933–',
+ role: '시인',
+ oneLine: '군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.',
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ name: '박화요비',
+ role: '가수',
+ oneLine: '군산 출신으로 소개되는 가수입니다.',
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.
+문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.
+
+[스키마]
+{ "kind":"people", "version":1, "title":"인물 열전", "items":[
+ { "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
+ "oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
+· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.
+· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`,
+ },
+
+ chronicle: {
+ kind: 'chronicle',
+ label: '시간의 골목',
+ sample: JSON.stringify(
+ {
+ kind: 'chronicle',
+ version: 1,
+ title: '시간의 골목',
+ subtitle: '이 도시를 만든 해들',
+ items: [
+ {
+ year: 1899,
+ title: '군산 개항',
+ summary: '항구가 열리며 도시의 성격이 한 번에 바뀝니다.',
+ place: '군산 내항',
+ turning: true,
+ verified: '확인',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ year: 1926,
+ title: '내항 부잔교 축조',
+ summary: '조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로이기도 했습니다.',
+ place: '군산 내항 부잔교',
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ year: 1937,
+ title: '『탁류』 연재 시작',
+ summary: '채만식이 이 도시를 소설의 무대로 세웁니다.',
+ place: '채만식문학관',
+ verified: '확인',
+ source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
+ },
+ {
+ year: 2010,
+ title: '새만금 방조제 개통',
+ summary: '간척으로 해안선이 다시 그려집니다.',
+ place: '새만금 방조제',
+ turning: true,
+ verified: '확인필요',
+ source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.
+가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.
+
+[스키마]
+{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[
+ { "year":1899, "title":"사건 이름", "summary":"두 문장 이내",
+ "place":"지금 가 볼 수 있는 자리", "turning":true,
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
+· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
+· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`,
+ },
+
+ literature: {
+ kind: 'literature',
+ label: '문학 서가',
+ sample: JSON.stringify(
+ {
+ kind: 'literature',
+ version: 1,
+ title: '문학 서가',
+ subtitle: '이 도시를 쓴 책들',
+ items: [
+ {
+ workTitle: '탁류',
+ author: '채만식',
+ year: '1937–1938 연재',
+ genre: '장편소설',
+ spineColor: '#7d3320',
+ background: '일제강점기 군산을 무대로 한 여자의 삶과 도시의 경제를 겹쳐 놓았습니다.',
+ whyHere: '소설 속 거리를 따라 걸으면 지금의 내항이 겹쳐 보입니다.',
+ verified: '확인',
+ source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
+ },
+ {
+ workTitle: '레디메이드 인생',
+ author: '채만식',
+ year: '1934',
+ genre: '단편소설',
+ spineColor: '#2a4a6b',
+ background: '배운 사람이 일자리를 얻지 못하는 식민지 도시의 앞뒤를 풍자로 잘라 보입니다.',
+ whyHere: '같은 작가의 문학관이 금강 하구 옆에 서 있습니다.',
+ verified: '확인필요',
+ source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
+ },
+ {
+ workTitle: '아리랑',
+ author: '조정래',
+ year: '대하소설',
+ genre: '장편소설',
+ spineColor: '#3f5c33',
+ background: '이 지역이 겪은 시간을 인물들의 이동으로 따라갑니다.',
+ whyHere: '스탬프투어 아리랑길이 소설과 같은 자리를 지납니다.',
+ verified: '확인필요',
+ source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[지역]을 배경으로 하거나 [지역]에서 쓰인 문학 작품을 8편까지 찾는다.
+소설·시·수필을 고루 섞고, 작품이 그 지역과 어떻게 닿는지 한 문장으로 적는다.
+
+[스키마]
+{ "kind":"literature", "version":1, "title":"문학 서가", "items":[
+ { "workTitle":"작품명", "author":"작가", "year":"1937", "genre":"장편소설",
+ "spineColor":"#7d3320", "background":"작품 배경 두 문장 이내",
+ "whyHere":"[지역]과 잇는 한 문장",
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· 작품 원문(구절·시행·문장)을 옮기지 않는다. 줄거리와 배경만 쓴다.
+· 저작권이 살아 있는 작품일수록 짧게 쓴다.
+· spineColor 는 책등 색이다. 작품 분위기에 맞는 어두운 색 하나를 hex 로 고른다.`,
+ },
+
+ postcard: {
+ kind: 'postcard',
+ label: '오늘의 엽서',
+ sample: JSON.stringify(
+ {
+ kind: 'postcard',
+ version: 1,
+ title: '오늘의 엽서',
+ subtitle: '그대로 붙여 쓰는 한 문장',
+ items: [
+ {
+ line: '부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.',
+ hashtags: ['#군산', '#내항', '#부잔교'],
+ place: '군산 내항',
+ postmark: '군산 內港',
+ verified: '확인',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ line: '짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.',
+ hashtags: ['#군산짬뽕', '#항구도시'],
+ place: '군산 중앙로',
+ postmark: '군산 中央',
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ line: '단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.',
+ hashtags: ['#이성당', '#오래된가게'],
+ place: '이성당',
+ postmark: '군산 中央',
+ verified: '확인',
+ source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
+사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
+
+[스키마]
+{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
+ { "line":"한 문장", "hashtags":["#태그"], "place":"장소",
+ "postmark":"소인에 찍을 짧은 지명",
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
+· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
+· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`,
+ },
+
+ quiz: {
+ kind: 'quiz',
+ label: '뒤집어 보는 질문',
+ sample: JSON.stringify(
+ {
+ kind: 'quiz',
+ version: 1,
+ title: '뒤집어 보는 질문',
+ subtitle: '뒤집으면 힌트가 나옵니다',
+ items: [
+ {
+ question: '부잔교는 왜 물 위에 뜨도록 만들었을까?',
+ hint: '서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.',
+ topic: '군산 내항',
+ level: '초등',
+ verified: '확인',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ question: '『탁류』의 “탁류”는 무엇을 가리키는 말일까?',
+ hint: '금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.',
+ topic: '채만식문학관',
+ level: '중등',
+ verified: '확인',
+ source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
+ },
+ {
+ question: '고군산군도의 “고(古)”는 왜 붙었을까?',
+ hint: '지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.',
+ topic: '고군산군도',
+ level: '어른',
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.
+질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다.
+
+[스키마]
+{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[
+ { "question":"질문 한 문장", "hint":"두 문장 이내 힌트",
+ "topic":"관련 장소·주제", "level":"초등|중등|어른",
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.
+· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.
+· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`,
+ },
+
+ planner: {
+ kind: 'planner',
+ label: '계절별 추천 하루',
+ sample: JSON.stringify(
+ {
+ kind: 'planner',
+ version: 1,
+ title: '계절별 추천 하루',
+ subtitle: '계절을 고르면 시각까지 짜 드립니다',
+ items: [
+ {
+ name: '개항장 골목 한 바퀴',
+ season: '가을',
+ rank: 1,
+ startTime: '09:30',
+ audience: '처음 온 손님',
+ why: '해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.',
+ stops: [
+ {name: '군산근대역사박물관', minutes: 90, moveMinutes: 15, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관'},
+ {name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
+ {name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당'},
+ {name: '경암동 철길마을', minutes: 60, moveMinutes: 20, note: '해 질 무렵 빛이 가장 좋습니다.', searchQuery: '군산 경암동 철길마을'},
+ ],
+ verified: '확인',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ {
+ name: '섬으로 건너가는 날',
+ season: '가을',
+ rank: 2,
+ startTime: '10:00',
+ audience: '차를 가져온 손님',
+ why: '바람이 차기 전 마지막으로 바다가 열리는 계절입니다.',
+ stops: [
+ {name: '고군산군도 선유도', minutes: 150, moveMinutes: 50, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도'},
+ {name: '장자도', minutes: 60, moveMinutes: 15, note: '섬과 섬 사이를 걸어 넘습니다.', searchQuery: '군산 장자도'},
+ ],
+ verified: '확인필요',
+ source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
+ },
+ {
+ name: '비 오면 여기로',
+ season: '여름',
+ rank: 1,
+ startTime: '10:30',
+ audience: '혼자 온 손님',
+ why: '장마철에 실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.',
+ stops: [
+ {name: '군산근대건축관', minutes: 60, moveMinutes: 12, note: '옛 은행 건물 안이 통째로 전시입니다.', searchQuery: '군산근대건축관'},
+ {name: '한일옥', minutes: 60, moveMinutes: 8, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
+ {name: '째보선창 근처 카페', minutes: 90, moveMinutes: 10, note: '비 그칠 때까지 앉아 있기 좋습니다.', searchQuery: '군산 째보선창 카페'},
+ ],
+ verified: '확인필요',
+ source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ task: `[해야 할 일]
+[업소]에 묵는 손님을 위한 [지역] 하루 코스를 **계절마다 1~3위**로 추천한다.
+계절 넷(봄·여름·가을·겨울)을 모두 채우고, 계절마다 성격이 다른 코스 세 개를 1·2·3위로 매긴다.
+코스마다 정거장 3~5곳을 **도는 순서대로** 적는다.
+
+★ 시각은 적지 않는다. 출발 시각(startTime)과 각 정거장의 머무는 시간(minutes)·이동 시간(moveMinutes)만
+적으면 화면이 시각을 계산해 채운다.
+
+[스키마]
+{ "kind":"planner", "version":1, "title":"계절별 추천 하루", "items":[
+ { "name":"코스 이름", "season":"봄|여름|가을|겨울", "rank":1,
+ "startTime":"09:30", "audience":"누구에게 맞는 하루",
+ "why":"왜 이 계절에 이 코스인가 (한 문장)",
+ "stops":[ {"name":"장소","minutes":90,"moveMinutes":15,
+ "note":"한 문장","searchQuery":"지도 검색어"} ],
+ "verified":"확인|확인필요",
+ "source":{"name":"출처명","url":"https://..."} } ] }`,
+ rules: `
+[이 아이템만의 규칙]
+· rank 는 계절마다 1·2·3 만 쓴다. 4위는 만들지 않는다 — 아무도 안 고른다.
+· 같은 계절의 세 코스가 서로 달라야 한다. 정거장이 겹치면 순위가 아니라 중복이다.
+· moveMinutes 는 앞 정거장에서 오는 시간이다. 첫 정거장은 [업소]에서 나서는 시간이다.
+· 걸어서 못 가는 곳은 note 에 "차로 이동" 이라고 적는다.
+· 영업시간·휴무일·요금은 넣지 않는다. 바뀌면 짜 준 일정이 손님을 헛걸음시킨다.
+· 장소에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.`,
+ },
+
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
return SECTION_DATA_SPEC[sectionType];
}
-
-export interface ParsedSectionData {
- items: T[];
- title?: string;
- subtitle?: string;
- /** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
- error?: string;
- /** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
- kindMismatch?: string;
- /** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
- unverified: number;
- /** source 가 붙은 항목 수. */
- sourced: number;
-}
-
-const EMPTY: ParsedSectionData = {items: [], unverified: 0, sourced: 0};
-
-/**
- * JSON.parse 실패를 "몇 번째 줄"로 바꾼다.
- *
- * ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과,
- * 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다.
- * 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다.
- */
-function locate(raw: string, message: string): string {
- const where = (pos: number) => {
- const before = raw.slice(0, Math.max(0, pos));
- const line = before.split('\n').length;
- const col = pos - before.lastIndexOf('\n');
- return `${line}번째 줄 ${col}번째 글자`;
- };
-
- const token = /Unexpected token '(.)'/.exec(message)?.[1];
- // 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다.
- const hint =
- token === '}' || token === ']'
- ? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.'
- : '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.';
-
- const lineCol = /line (\d+) column (\d+)/.exec(message);
- if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`;
-
- const at = /position (\d+)/.exec(message);
- if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`;
-
- // 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다.
- const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1];
- const found = quoted ? raw.indexOf(quoted) : -1;
- if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`;
-
- return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.';
-}
-
-/**
- * 붙여넣은 문자열 → 렌더 가능한 항목.
- *
- * ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
- */
-export function parseSectionData(
- sectionType: string,
- raw: string | undefined,
-): ParsedSectionData {
- const spec = SECTION_DATA_SPEC[sectionType];
- const text = (raw ?? '').trim();
- if (!spec || !text) return EMPTY as ParsedSectionData;
-
- let parsed: unknown;
- try {
- parsed = JSON.parse(text);
- } catch (error) {
- return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')};
- }
-
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
- return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'};
- }
-
- const envelope = parsed as Record;
- const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined;
- const rawItems = envelope.items;
- if (!Array.isArray(rawItems)) {
- return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'};
- }
-
- const items = rawItems.filter(
- (item): item is T =>
- typeof item === 'object' &&
- item !== null &&
- !Array.isArray(item) &&
- typeof (item as Record)[spec.requiredKey] === 'string' &&
- ((item as Record)[spec.requiredKey] as string).trim().length > 0,
- );
-
- let unverified = 0;
- let sourced = 0;
- for (const item of items) {
- const row = item as Record;
- if (row.verified !== '확인') unverified += 1;
- if (row.source && typeof row.source === 'object') sourced += 1;
- }
-
- return {
- items,
- title: typeof envelope.title === 'string' ? envelope.title : undefined,
- subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined,
- kindMismatch: kind && kind !== spec.kind ? kind : undefined,
- unverified,
- sourced,
- };
-}
diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts
index ab28cf0..2fb4c78 100644
--- a/solution/frontend/src/features/builder/canvas/registry.ts
+++ b/solution/frontend/src/features/builder/canvas/registry.ts
@@ -68,6 +68,12 @@ import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
+import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
+import {ChronicleRail} from './variants/chronicle/ChronicleRail';
+import {LiteratureShelf} from './variants/literature/LiteratureShelf';
+import {PostcardStack} from './variants/postcard/PostcardStack';
+import {QuizFlip} from './variants/quiz/QuizFlip';
+import {PlannerPodium} from './variants/planner/PlannerPodium';
export const SECTION_VARIANTS: Record = {
hero: [
@@ -447,6 +453,72 @@ export const SECTION_VARIANTS: Record = {
},
],
+ people: [
+ {
+ id: 'people.filmstrip',
+ name: '필름 스트립',
+ description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.',
+ thumb: 'carousel',
+ Component: PeopleFilmstrip,
+ isDefault: true,
+ },
+ ],
+
+ chronicle: [
+ {
+ id: 'chronicle.rail',
+ name: '가로 연표',
+ description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.',
+ thumb: 'timeline',
+ Component: ChronicleRail,
+ isDefault: true,
+ },
+ ],
+
+ literature: [
+ {
+ id: 'literature.shelf',
+ name: '책등 서가',
+ description: '책등이 가로로 흐르고 고른 책만 세로쓰기로 펼쳐진다.',
+ thumb: 'carousel',
+ Component: LiteratureShelf,
+ isDefault: true,
+ },
+ ],
+
+ postcard: [
+ {
+ id: 'postcard.stack',
+ name: '엽서 뒷면',
+ description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.',
+ thumb: 'carousel',
+ Component: PostcardStack,
+ isDefault: true,
+ },
+ ],
+
+ quiz: [
+ {
+ id: 'quiz.flip',
+ name: '시험지 플립',
+ description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.',
+ thumb: 'cards',
+ Component: QuizFlip,
+ isDefault: true,
+ },
+ ],
+
+ planner: [
+ {
+ id: 'planner.podium',
+ name: '시즌 랭킹',
+ description: '계절을 고르면 1·2·3위 하루가 뜬다. 시각은 계산해서 채운다.',
+ thumb: 'timeline',
+ Component: PlannerPodium,
+ isDefault: true,
+ },
+ ],
+
exhibition: [
{
id: 'exhibition.gallery',
diff --git a/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx b/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx
new file mode 100644
index 0000000..6d232c7
--- /dev/null
+++ b/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx
@@ -0,0 +1,139 @@
+/**
+ * 시간의 골목 — 가로 연표.
+ *
+ * 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 —
+ * 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다.
+ * ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
+ */
+import {useMemo} from 'react';
+import {SectionBody, SectionFrame} from '../../primitives';
+import type {SectionRenderProps} from '../../types';
+import {parseSectionData, type ChronicleItem} from '@o2o/shared';
+import {
+ CarouselNav,
+ ITEM_ACCENT,
+ ITEM_BODY,
+ ITEM_BORDER,
+ ITEM_CARD,
+ ITEM_HEADING,
+ ITEM_INK,
+ ParseError,
+ PasteHint,
+ SourceLine,
+ useCarousel,
+} from '../items/common';
+import '../items/items.css';
+
+function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
+ const turning = item.turning === true;
+
+ return (
+
+
+ {item.year ?? '연도 미상'}
+
+
+ {/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */}
+
+
+
+
+
+
+
+ {item.title}
+
+ {item.summary && (
+
+ {item.summary}
+
+ )}
+ {item.place &&
지금 이 자리 · {item.place}
}
+
+
+
+ );
+}
+
+export function ChronicleRail(props: SectionRenderProps) {
+ const {section, isSelected, onSelect} = props;
+ const parsed = parseSectionData(section.type, section.data);
+ const {ref, scrollBy} = useCarousel();
+
+ // 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
+ const items = useMemo(
+ () =>
+ [...parsed.items].sort((a, b) => {
+ if (a.year == null) return b.year == null ? 0 : 1;
+ if (b.year == null) return -1;
+ return a.year - b.year;
+ }),
+ [parsed.items],
+ );
+
+ const turningCount = items.filter((item) => item.turning === true).length;
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
+ *
+ * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
+ * ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다.
+ */
+export function PasteHint({label}: {label: string}) {
+ return (
+
+
+
{label} 내용이 아직 없습니다
+
+ 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
+
+
+ );
+}
+
+/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */
+export function ParseError({message}: {message: string}) {
+ return (
+
+
붙여넣은 JSON 을 읽지 못했습니다
+
{message}
+
+ );
+}
diff --git a/solution/frontend/src/features/builder/canvas/variants/items/items.css b/solution/frontend/src/features/builder/canvas/variants/items/items.css
new file mode 100644
index 0000000..fdf7bd2
--- /dev/null
+++ b/solution/frontend/src/features/builder/canvas/variants/items/items.css
@@ -0,0 +1,108 @@
+/**
+ * 붙여넣기 아이템의 질감 — Tailwind 로는 못 그리는 것만 여기 둔다.
+ * 도넛판 홈, 종이 결, 톱니, 필름 구멍, 시험지 뒤집기.
+ *
+ * ★ 색은 전부 템플릿 토큰(--tpl-*)에서 받는다. 한때 갱지색·먹색을 hex 로 박아 뒀는데,
+ * 템플릿을 바꿔도 이 질감들만 레트로 색으로 남아 화면이 두 벌로 보였다.
+ */
+
+/* 종이 결 — 두 방향이 겹쳐야 종이로 읽힌다. 글자색을 옅게 깔아 어떤 팔레트에서도 결이 보인다. */
+.w4-paper {
+ background-image:
+ repeating-linear-gradient(0deg, color-mix(in oklab, currentcolor 4%, transparent) 0 1px, transparent 1px 3px),
+ repeating-linear-gradient(90deg, color-mix(in oklab, currentcolor 3%, transparent) 0 1px, transparent 1px 4px);
+}
+
+/* 도넛판. --lbl 이 라벨 색이다. */
+.w4-disc {
+ /* 판의 검정은 템플릿의 어두운 면(--tpl-inverse)이다. 홈은 그 위에 밝기 차로만 판다. */
+ background:
+ radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl) 15.5% 33%, transparent 33%),
+ repeating-radial-gradient(
+ circle at 50% 50%,
+ color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.4px,
+ var(--w4-vinyl) 1.4px 3px
+ ),
+ var(--w4-vinyl);
+ box-shadow: inset 0 0 40px rgb(0 0 0 / 55%);
+}
+.w4-disc-sheen {
+ background: conic-gradient(
+ from 210deg,
+ rgb(255 255 255 / 12%),
+ transparent 22%,
+ transparent 70%,
+ rgb(255 255 255 / 7%)
+ );
+}
+.w4-spin {
+ animation: w4-rev 2.2s linear infinite;
+}
+@keyframes w4-rev {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* 미니 판 — 캐러셀에 늘어서는 작은 것. */
+.w4-disc-mini {
+ background:
+ radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl) 14% 34%, transparent 34%),
+ repeating-radial-gradient(
+ circle at 50% 50%,
+ color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.2px,
+ var(--w4-vinyl) 1.2px 2.6px
+ ),
+ var(--w4-vinyl);
+ box-shadow: 0 3px 10px rgb(0 0 0 / 35%);
+}
+
+/* 일력 톱니 — 뜯어낸 자국. --tear 는 섹션 바탕색이 들어온다(뜯긴 자리로 바탕이 비쳐야 한다). */
+.w4-perf {
+ background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear) 8px 9px);
+}
+
+/* 승차권 절취선 */
+.w4-dash {
+ border-top: 1px dashed currentcolor;
+}
+
+/* 필름 퍼포레이션 — 위아래 구멍이 있어야 한 장면이 아니라 '롤'로 읽힌다.
+ 구멍은 currentColor(어두운 면 위의 글자색)로 뚫어 팔레트를 따른다. */
+.w4-film-perf {
+ background: repeating-linear-gradient(90deg, currentcolor 0 9px, transparent 9px 21px);
+}
+
+/* 시험지 뒤집기. 카드가 얇아 보이지 않게 두 면을 같은 자리에 겹쳐 둔다. */
+.w4-flip {
+ perspective: 900px;
+}
+.w4-flip-inner {
+ transform-style: preserve-3d;
+ transition: transform 0.5s;
+}
+.w4-flip-on .w4-flip-inner {
+ transform: rotateY(180deg);
+}
+.w4-flip-face {
+ backface-visibility: hidden;
+}
+.w4-flip-back {
+ transform: rotateY(180deg);
+}
+
+.w4-scroll {
+ scrollbar-width: none;
+}
+.w4-scroll::-webkit-scrollbar {
+ display: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .w4-spin {
+ animation: none;
+ }
+ .w4-flip-inner {
+ transition: none;
+ }
+}
diff --git a/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx b/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx
new file mode 100644
index 0000000..bf34c19
--- /dev/null
+++ b/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx
@@ -0,0 +1,149 @@
+/**
+ * 문학 서가 — 책등 캐러셀 + 세로쓰기 펼침면.
+ *
+ * 책등이 가로로 흐르고, 하나를 고르면 그 책만 앞으로 나와 펼쳐진다.
+ * 펼친 면의 제목은 **세로쓰기**다 — 원문을 못 싣는 대신(DB_Guide: 원문 전재 금지)
+ * 활자의 결로 문학을 느끼게 하는 자리다.
+ */
+import {useState} from 'react';
+import {SectionBody, SectionFrame} from '../../primitives';
+import type {SectionRenderProps} from '../../types';
+import {parseSectionData, type LiteratureItem} from '@o2o/shared';
+import {
+ CarouselNav,
+ ITEM_ACCENT,
+ ITEM_BODY,
+ ITEM_BORDER,
+ ITEM_CARD,
+ ITEM_HEADING,
+ ITEM_INVERSE_INK,
+ ParseError,
+ PasteHint,
+ SourceLine,
+ useCarousel,
+} from '../items/common';
+import '../items/items.css';
+
+/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다 — 색을 박으면 팔레트를 바꿔도 서가만 남는다. */
+const FALLBACK_SPINE = ITEM_ACCENT;
+
+export function LiteratureShelf(props: SectionRenderProps) {
+ const {section, isSelected, onSelect} = props;
+ const parsed = parseSectionData(section.type, section.data);
+ const [opened, setOpened] = useState(0);
+ const {ref, scrollBy} = useCarousel();
+
+ // 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다.
+ const current = parsed.items[opened] ?? parsed.items[0];
+
+ return (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx b/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx
new file mode 100644
index 0000000..9c034e0
--- /dev/null
+++ b/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx
@@ -0,0 +1,153 @@
+/**
+ * 인물 열전 — 필름 스트립.
+ *
+ * 프레임 하나가 인물 하나다. 사진 자리에는 **활판 이니셜**이 들어간다 —
+ * 이 데이터에는 쓸 수 있는 인물 사진이 없고, 빈 회색 상자를 두면 "사진을 못 넣은 화면"으로 읽힌다.
+ * 이니셜을 넣으면 없는 것이 형식이 된다.
+ * ★ 이미지 URL 을 받지 않는다(dataSpec: imageQuery). 초상권 확인은 사장님 몫이라 검색어까지만 싣는다.
+ */
+import {useState} from 'react';
+import {cn} from '@/lib/utils';
+import {SectionBody, SectionFrame} from '../../primitives';
+import type {SectionRenderProps} from '../../types';
+import {parseSectionData, type PeopleItem} from '@o2o/shared';
+import {
+ CarouselNav,
+ ITEM_ACCENT,
+ ITEM_BODY,
+ ITEM_HEADING,
+ ITEM_INVERSE,
+ ParseError,
+ PasteHint,
+ SourceLine,
+ useCarousel,
+} from '../items/common';
+import '../items/items.css';
+
+/** 이니셜 한 글자. 성이 두 글자인 이름도 첫 자만 — 프레임이 좁아 두 글자는 안 읽힌다. */
+function initialOf(name: string): string {
+ return name.trim().charAt(0) || '?';
+}
+
+export function PeopleFilmstrip(props: SectionRenderProps) {
+ const {section, isSelected, onSelect} = props;
+ const parsed = parseSectionData(section.type, section.data);
+ const [picked, setPicked] = useState(0);
+ const {ref, scrollBy} = useCarousel();
+
+ // 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
+ const current = parsed.items[picked] ?? parsed.items[0];
+
+ return (
+
+ {/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */}
+
+
- );
-}
-
-/**
- * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
- *
- * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
- */
-export function PasteHint({label}: {label: string}) {
- return (
-
-
-
{label} 내용이 아직 없습니다
-
- 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
-
-
- );
-}
-
-/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다. */
-export function ParseError({message}: {message: string}) {
- return (
-
+
+ ))}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx
new file mode 100644
index 0000000..7ed82b7
--- /dev/null
+++ b/solution/site/src/sections/items/PeopleSection.tsx
@@ -0,0 +1,59 @@
+/**
+ * 인물 열전 — 발행본. 필름 한 롤에 프레임이 이어진다.
+ * ★ 사진 자리는 활판 이니셜이다. 이미지 URL 을 받지 않기 때문에(imageQuery 만) 빈 상자 대신 활자를 둔다.
+ */
+import type {PeopleItem} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
+
+export function PeopleSection() {
+ const payload = useSite();
+ const parsed = sectionItems(payload, 'people');
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/QuizSection.tsx b/solution/site/src/sections/items/QuizSection.tsx
new file mode 100644
index 0000000..d8827a1
--- /dev/null
+++ b/solution/site/src/sections/items/QuizSection.tsx
@@ -0,0 +1,54 @@
+/**
+ * 뒤집어 보는 질문 — 발행본.
+ *
+ * ★ 캔버스처럼 뒤집지 않는다. 뒤집으면 힌트 문장이 HTML 에 있어도 화면에 없는 것처럼 읽히고,
+ * 무엇보다 종이로 뽑아 로비에 두는 쓰임이 이 아이템의 절반이다 — 앞뒤를 함께 인쇄한다.
+ * ★ 정답 칸은 없다. 이 데이터는 검증되지 않은 줄이 더 많아 단정하면 틀린 걸 단정한다.
+ */
+import type {QuizItem} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine} from './common';
+
+export function QuizSection() {
+ const payload = useSite();
+ const parsed = sectionItems(payload, 'quiz');
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+
+ {parsed.items.map((item, index) => (
+
+
+
+
+ {slots.map((slot, index) => {
+ const {head, tail} = splitTime(slot.time ?? '');
+ return (
+
+ {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
+ {/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */}
+
+
+ {head}
+ {tail && :{tail}}
+
+
+
+
+
{slot.title}
+ {slot.place && (
+
+ {slot.place}
+
+ )}
+ {slot.note &&
{slot.note}
}
+
+
+ {slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}
+ {slot.searchQuery && 지도 검색 · {slot.searchQuery}}
+
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/SongsSection.tsx b/solution/site/src/sections/items/SongsSection.tsx
new file mode 100644
index 0000000..86e4365
--- /dev/null
+++ b/solution/site/src/sections/items/SongsSection.tsx
@@ -0,0 +1,78 @@
+/**
+ * 가요 다방 — 발행본.
+ *
+ * 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다.
+ * 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다.
+ * ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지).
+ */
+import type {SongItem} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
+
+/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */
+const FALLBACK_LABEL = ITEM_ACCENT;
+
+export function SongsSection() {
+ const payload = useSite();
+ const parsed = sectionItems(payload, 'songs');
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+
+ {parsed.items.map((song, index) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx
new file mode 100644
index 0000000..bf090e0
--- /dev/null
+++ b/solution/site/src/sections/items/common.tsx
@@ -0,0 +1,140 @@
+/**
+ * 붙여넣기 아이템의 발행본 공통 조각.
+ *
+ * ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--tpl-*`, `seo/head.ts` 가 심는다)을 읽는다 —
+ * 한때 이 파일이 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, 그 바람에
+ * 사장님이 템플릿을 바꿔도 아이템 섹션만 레트로로 남았다. 아이템은 레트로 전용 부품이 아니다.
+ * ★ 인상은 빌더 캔버스와 같게 그린다. 다만 **인터랙션은 옮기지 않는다** —
+ * 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다.
+ * 이 사이트의 존재 이유가 AI·검색의 인용이라, 발행본은 전 항목을 펴고 가로로만 민다.
+ */
+import type {ReactNode} from 'react';
+import type {DataSource, DataVerified} from '@o2o/shared';
+import './items.css';
+
+/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
+export const ITEM_CARD = 'var(--tpl-card, #fafafa)';
+/** 선·테두리. */
+export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)';
+/**
+ * 강조 — 연표의 전환점, 일력의 날짜, 소인 도장.
+ *
+ * ★ 강조색을 그대로 쓰지 않고 둘레 글자색을 섞는다(빌더 캔버스와 같은 규칙). 팔레트에 따라
+ * accent 가 바탕과 같은 밝기면 큰 숫자와 배지가 화면에서 사라진다.
+ */
+export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)';
+/** 어두운 면(필름·플립보드). */
+export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)';
+/** 어두운 면 위의 글자색 — 흰색을 박지 않고 템플릿의 밝은 면 색을 쓴다. */
+export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)';
+/** 글자색. */
+export const ITEM_INK = 'var(--tpl-text, #09090b)';
+
+/**
+ * 아이템 섹션 껍데기.
+ *
+ * 바탕은 `--tpl-surface`(섹션 바탕) 또는 `--tpl-inverse`(어두운 아이템)다.
+ * 다른 섹션들이 쓰는 `border-black/8` 대신 토큰 테두리를 쓴다 — 어두운 면에서 검은 선은 안 보인다.
+ */
+export function ItemSection({
+ id,
+ name,
+ subtitle,
+ count,
+ children,
+ dark,
+}: {
+ id: string;
+ name: string;
+ subtitle?: string;
+ /** 각주 한 줄("총 8곡 · 확인 필요 2"). 없으면 각주를 그리지 않는다. */
+ count?: string;
+ children: ReactNode;
+ dark?: boolean;
+}) {
+ return (
+
+
+ );
+}
+
+/**
+ * 출처 한 줄 + 확신 배지.
+ *
+ * ★ 확인되지 않은 값을 숨기지 않고 드러낸다 — 발행본에서도 같다. 손님이 그 문장을
+ * 그대로 옮겨 적을 수 있어서, 어디까지가 확인된 것인지 화면이 말해야 한다.
+ * ★ 배지 색만은 토큰이 아니다. '확인/확인필요'는 디자인이 아니라 신호라, 팔레트를 따라가다
+ * 경고가 안 보이는 색이 되면 안 된다.
+ */
+export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) {
+ if (!source && !verified) return null;
+ return (
+