diff --git a/solution/shared/src/lib/color.ts b/solution/shared/src/lib/color.ts index 821c275..4be2645 100644 --- a/solution/shared/src/lib/color.ts +++ b/solution/shared/src/lib/color.ts @@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string; surface: mix(colors.bg, colors.text, 0.06), surfaceAlt: colors.card, inverse: '#1c1917', - border: colors.secondary, + /** + * 선 색. + * + * ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라 + * 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히). + * 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e). + */ + border: mix(colors.bg, colors.text, 0.2), }; } diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index be4c7ec..ce8cb5b 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -85,10 +85,16 @@ export interface PeopleItem { role?: string; oneLine?: string; /** - * 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고, - * 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약). + * 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다. */ imageQuery?: string; + /** + * 초상 사진. + * + * ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나, + * 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다. + */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -101,6 +107,8 @@ export interface ChronicleItem { place?: string; /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ turning?: boolean; + /** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -124,6 +132,8 @@ export interface PostcardItem { place?: string; /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ postmark?: string; + /** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -138,6 +148,56 @@ export interface QuizItem { source?: DataSource; } +export interface ItineraryStop { + name: string; + minutes?: number; + note?: string; + searchQuery?: string; + /** + * 좌표. + * + * ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이 + * 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다. + * 없으면 지도를 그리지 않고 타임라인만 낸다. + */ + latitude?: number; + longitude?: number; + /** 사진. 권리가 확실한 출처만. */ + imageUrl?: string; +} + +export interface ItineraryDay { + label: string; + startTime?: string; + stops?: ItineraryStop[]; +} + +/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */ +export interface ItineraryItem { + name: string; + duration?: string; + audience?: string; + why?: string; + days?: ItineraryDay[]; + verified?: DataVerified; + source?: DataSource; +} + +/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */ +export interface EventItem { + kind?: string; + title: string; + summary?: string; + body?: string; + verified?: DataVerified; +} + +export interface VideoItem { + url: string; + caption?: string; + verified?: DataVerified; +} + export interface PlannerStop { name: string; /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ @@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record = { postcard: 'line', quiz: 'question', planner: 'name', + // ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다. + itinerary: 'name', + event: 'title', + video: 'url', }; export interface ParsedSectionData { diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 15db900..dd491af 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -106,6 +106,13 @@ export interface TemplateLook { headingWeight: string; /** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */ sectionSpace: string; + /** + * 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`). + * + * ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서 + * 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다. + */ + texture?: string; } export interface PhotoItem { diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index ccd217c..1fd1bb8 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -189,6 +189,14 @@ export interface LocalPlace { description?: string; /** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */ searchQuery: string; + /** + * 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다. + * + * ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다. + */ + imageUrl?: string; + /** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */ + distanceMeters?: number; } export interface FestivalEntry { @@ -199,6 +207,8 @@ export interface FestivalEntry { description?: string; officialUrl?: string; searchQuery: string; + /** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */ + imageUrl?: string; } export interface RouteEntry { diff --git a/solution/site/src/index.css b/solution/site/src/index.css index 7977d64..597707d 100644 --- a/solution/site/src/index.css +++ b/solution/site/src/index.css @@ -15,6 +15,28 @@ --tpl-card: #fafafa; --tpl-text: #09090b; --tpl-accent: #2563eb; + + /* + * 유동 타이포 — 화면 폭에 따라 자라는 글자 크기. + * + * ★ 이 토큰이 없던 동안 히어로 제목이 본문과 같은 크기로 나왔다. 섹션 컴포넌트들이 + * var(--fs-display) 를 쓰는데 정의가 어디에도 없어 그냥 무시됐다(브라우저는 조용히 넘어간다). + * ★ 값은 목업(/s/stay)이 쓰던 것을 그대로 옮겼다 — 화면이 달라지면 목업과 어긋난다. + */ + --fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem); + --fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem); + --fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem); + --fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem); + --fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem); + --fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem); + --fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem); + + /* 섹션 세로 여백. 템플릿의 sectionSpace 를 상한으로 두고 좁은 화면에서만 줄인다. */ + --section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem)); + + /* 선·흐린 글자 — 템플릿 색에서 유도한다. 고정 회색을 쓰면 갱지 바탕에서 뜬다. */ + --color-line: var(--tpl-border, color-mix(in oklab, var(--tpl-text, #09090b) 14%, transparent)); + --color-muted: color-mix(in oklab, var(--tpl-text, #09090b) 60%, transparent); } @theme inline { @@ -68,4 +90,91 @@ body { padding-inline: 1.5rem; } } + + /* 본문 한 줄 길이 상한. 68자를 넘기면 눈이 다음 줄을 못 찾는다. */ + .measure { + max-width: 68ch; + } + + /* 종이 질감 — 템플릿이 texture 를 주면 깔린다(레트로 갱지). 없으면 아무것도 안 깔린다. */ + .paper { + background-image: var(--tpl-texture, none); + } + + /* 카드·패널. 바탕보다 한 겹 앞으로 나온 면. */ + .panel { + background-color: color-mix(in oklab, currentcolor 5%, transparent); + border: var(--tpl-border-width, 1px) solid var(--color-line); + border-radius: var(--tpl-radius, 0.75rem); + box-shadow: var(--tpl-shadow, none); + } + .panel-sunken { + background-color: color-mix(in oklab, currentcolor 6%, transparent); + } + + .border-line { + border-color: var(--color-line); + } + /* divide-y 의 사이 선도 같은 색으로. Tailwind 기본은 회색이라 갱지 위에서 뜬다. */ + .divide-line > :not([hidden]) ~ :not([hidden]) { + border-color: var(--color-line); + } + .text-muted { + color: var(--color-muted); + } + + /* 소제목. h1 은 히어로가 인라인으로 쓰고, 그 아래 단계는 이 두 클래스가 맡는다. */ + .h2 { + font-family: var(--tpl-font-heading, var(--font-serif)); + font-size: var(--fs-h2); + font-weight: var(--tpl-heading-weight, 700); + letter-spacing: var(--tpl-heading-tracking, -0.01em); + line-height: 1.2; + } + .h3 { + font-family: var(--tpl-font-heading, var(--font-serif)); + font-size: var(--fs-h3); + font-weight: var(--tpl-heading-weight, 700); + letter-spacing: var(--tpl-heading-tracking, -0.005em); + line-height: 1.35; + } + /* 간판체처럼 굵기가 한 벌뿐인 서체에 가짜 볼드가 씌워지는 것을 막는다. */ + .serif, + .h2, + .h3 { + font-synthesis-weight: none; + } + + .label { + font-size: var(--fs-xs); + color: var(--color-muted); + font-weight: 600; + } + + .tpl-shadow { + box-shadow: var(--tpl-shadow, none); + } +} + +@layer components { + /* + * 가로 레일 — 목업과 같은 클래스 이름. 캐러셀 라이브러리 없이 스크롤 스냅으로만 만든다. + * ★ 스크립트가 없어도 손가락·트랙패드로 밀린다. 라이브러리를 얹으면 그게 죽었을 때 + * 레일 전체가 한 줄로 굳는다. + */ + .slider-viewport { + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.75rem; + } + .slider-viewport::-webkit-scrollbar { + display: none; + } + .slider-track { + display: flex; + scroll-snap-type: x mandatory; + } + .slider-track > * { + scroll-snap-align: center; + } } diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index cbc3635..c194473 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -5,6 +5,7 @@ import { EssentialInfoSection, ExhibitionSection, FaqSection, + FestivalSection, GallerySection, HeroSection, InquirySection, @@ -14,11 +15,20 @@ import { LocationSection, RulesSection, SpaceSection, + StorySection, UnitsSection, } from '@/sections'; import {useSite} from '@/lib/site-context'; import {isSectionEnabled} from '@/lib/derive'; +/** + * StorySection 이 탭으로 묶어 그리는 아이템. + * + * ★ `sections/StorySection.tsx` 의 STORY_KINDS 와 같은 목록이어야 한다. + * 어긋나면 어떤 아이템은 탭 밖에 따로 서고, 어떤 아이템은 어디에도 안 나온다. + */ +const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily']; + /** * 홈. * @@ -59,12 +69,16 @@ export function HomePage() { exhibition: ExhibitionSection, photos: GallerySection, local: LocalGuideSection, + festival: FestivalSection, weather: WeatherSection, map: LocationSection, faq: FaqSection, - // 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. + // 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. // ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다. ...ITEM_SECTIONS, + // ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다. + // 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다. + ...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])), }; const rendered = new Set(); diff --git a/solution/site/src/sections/AboutSection.tsx b/solution/site/src/sections/AboutSection.tsx index 8c74e49..09764a8 100644 --- a/solution/site/src/sections/AboutSection.tsx +++ b/solution/site/src/sections/AboutSection.tsx @@ -1,6 +1,13 @@ import {useSite} from '@/lib/site-context'; import {galleryImages, sectionBody} from '@/lib/derive'; +/** + * 소개. + * + * ★ 마크업은 목업(/s/stay 의 `
`)에서 그대로 옮긴 것이다. + * 비슷하게 다시 짜지 않는다 — 그리드 비율(7:5)·모서리(rounded-xl)·본문 행간(1.8)이 + * 조금만 달라도 같은 페이지로 안 보인다. + */ export function AboutSection() { const payload = useSite(); const {narrative, place} = payload; @@ -17,15 +24,14 @@ export function AboutSection() {
-

About

- -
+
{image && ( -
-
+
+
{image.alt}
- {image.caption && ( -
{image.caption}
- )}
)} -
- {/* 제목은 사장님이 붙인 섹션 이름이 먼저다. 이름이 비어 있을 때만 - 지금까지 쓰던 문구(한 줄 소개 → "○○ 소개")로 떨어진다. */} -

- {narrative.heroSubline ?? `${place.name} 소개`} +
+

+