-
{monthLabel(month)}
+
{monthLabel(month)}
{title}
diff --git a/src/features/town/town-scene.ts b/src/features/town/town-scene.ts
index 5aa6df2..93efc45 100644
--- a/src/features/town/town-scene.ts
+++ b/src/features/town/town-scene.ts
@@ -63,11 +63,11 @@ type Season = {
}
const SEASONS: Season[] = [
- { at: 0.5, sky: 0xdfe6ec, grass: 0xb3b8a6, ginkgo: 0x8d7b5a, cherry: 0x8d7b5a, canopy: 0.0, snow: 0.92, sunEl: 0.5, sunI: 2.3, sun: 0xfff0dc, ambI: 1.0 },
- { at: 3.4, sky: 0xe3edf1, grass: 0x9dc37c, ginkgo: 0xa9cf6e, cherry: 0xf4c3d3, canopy: 1.0, snow: 0.0, sunEl: 0.85, sunI: 2.7, sun: 0xfff6e8, ambI: 0.85 },
- { at: 6.6, sky: 0xd5e8f2, grass: 0x6aa64e, ginkgo: 0x4f9437, cherry: 0x4c8f35, canopy: 1.08, snow: 0.0, sunEl: 1.05, sunI: 2.95, sun: 0xfffaf0, ambI: 0.8 },
- { at: 9.6, sky: 0xece5d8, grass: 0xb1ad76, ginkgo: 0xf1c230, cherry: 0xd2683a, canopy: 0.95, snow: 0.0, sunEl: 0.72, sunI: 2.6, sun: 0xffe7c4, ambI: 0.85 },
- { at: 11.3, sky: 0xe2e3e0, grass: 0xa9ab8c, ginkgo: 0xd8b24a, cherry: 0xa4643e, canopy: 0.25, snow: 0.2, sunEl: 0.55, sunI: 2.4, sun: 0xfff0dc, ambI: 0.95 },
+ { at: 0.5, sky: 0xe6eef4, grass: 0xd5dccd, ginkgo: 0x8d7b5a, cherry: 0x8d7b5a, canopy: 0.0, snow: 0.92, sunEl: 0.5, sunI: 2.3, sun: 0xfff0dc, ambI: 1.0 },
+ { at: 3.4, sky: 0xdcefff, grass: 0xa9dc86, ginkgo: 0xb5e89e, cherry: 0xf4c3d3, canopy: 1.0, snow: 0.0, sunEl: 0.85, sunI: 2.7, sun: 0xfff6e8, ambI: 0.85 },
+ { at: 6.6, sky: 0xd3ecff, grass: 0x86cf62, ginkgo: 0x5fb83c, cherry: 0x5aae3a, canopy: 1.08, snow: 0.0, sunEl: 1.05, sunI: 2.95, sun: 0xfffaf0, ambI: 0.8 },
+ { at: 9.6, sky: 0xeef0f2, grass: 0xc8cf8a, ginkgo: 0xffd84a, cherry: 0xd2683a, canopy: 0.95, snow: 0.0, sunEl: 0.72, sunI: 2.6, sun: 0xffe7c4, ambI: 0.85 },
+ { at: 11.3, sky: 0xe9ecee, grass: 0xc9cfb1, ginkgo: 0xd8b24a, cherry: 0xa4643e, canopy: 0.25, snow: 0.2, sunEl: 0.55, sunI: 2.4, sun: 0xfff0dc, ambI: 0.95 },
]
function seasonAt(m: number): { a: Season; b: Season; k: number } {
@@ -86,7 +86,7 @@ function seasonAt(m: number): { a: Season; b: Season; k: number } {
/* ------------------------------------------------------------------
개월 수 키프레임 — 가족이 서 있는 곳과 카메라가 보는 곳.
- 구간 사이에서 smoothstep 으로 이으므로, 도장에 찍힌 개월 수에서 가족이 "도착"한다.
+ 구간 사이에서 smoothstep 으로 이으므로, 배지에 적힌 개월 수에서 가족이 "도착"한다.
------------------------------------------------------------------ */
type Place = 'home' | 'daycare' | 'tree' | 'clinic' | 'center' | 'town'
@@ -94,15 +94,15 @@ type Beat = { m: number; at: [number, number]; look: [number, number]; dist: num
/* 좌표는 아래 배치(PLACES)와 같은 동네 지도를 본다. 가족은 보도(±4.9) 위를 걷는다. */
const BEATS: Beat[] = [
- { m: 0, at: [-26, -5.2], look: [-20, -8], dist: 92, focus: 'home' },
- { m: 6, at: [15, -5.2], look: [13, -10], dist: 74, focus: 'daycare' },
- { m: 12, at: [11.5, 11.5], look: [13, 12], dist: 70, focus: 'tree' },
- { m: 18, at: [-11.5, -5.2], look: [-10, -9], dist: 72, focus: 'clinic' },
- { m: 24, at: [-5.2, 14], look: [-11, 12], dist: 76, focus: 'center' },
- { m: 36, at: [17, -5.2], look: [13, -10], dist: 80, focus: 'daycare' },
- { m: 48, at: [18, 12], look: [9, 8], dist: 90, focus: 'tree' },
- { m: 60, at: [5.2, 9], look: [0, 0], dist: 104, focus: 'town' },
- { m: 72, at: [-24, -5.2], look: [-4, -2], dist: 122, focus: 'town' },
+ { m: 0, at: [-26, -5.2], look: [-20, -8], dist: 78, focus: 'home' },
+ { m: 6, at: [15, -5.2], look: [13, -10], dist: 60, focus: 'daycare' },
+ { m: 12, at: [11.5, 11.5], look: [13, 12], dist: 56, focus: 'tree' },
+ { m: 18, at: [-11.5, -5.2], look: [-10, -9], dist: 58, focus: 'clinic' },
+ { m: 24, at: [-5.2, 14], look: [-11, 12], dist: 62, focus: 'center' },
+ { m: 36, at: [17, -5.2], look: [13, -10], dist: 66, focus: 'daycare' },
+ { m: 48, at: [18, 12], look: [9, 8], dist: 76, focus: 'tree' },
+ { m: 60, at: [5.2, 9], look: [0, 0], dist: 88, focus: 'town' },
+ { m: 72, at: [-24, -5.2], look: [-4, -2], dist: 104, focus: 'town' },
]
function beatAt(m: number): { a: Beat; b: Beat; k: number } {
@@ -145,8 +145,8 @@ export function mount(host: HTMLElement): TownHandle {
/* r18x 부터 PCFSoftShadowMap 은 없어졌고 PCFShadowMap 이 부드러운 쪽을 맡는다 */
renderer.shadowMap.type = THREE.PCFShadowMap
renderer.toneMapping = THREE.ACESFilmicToneMapping
- /* 종이색 지면 위에 앉는 장면이라 한낮에도 하얗게 뜨지 않게 조금 누른다 */
- renderer.toneMappingExposure = 0.96
+ /* 흰 벽이 많은 장면이라 1 을 크게 넘기면 벽과 보도가 하얗게 날아간다 */
+ renderer.toneMappingExposure = 1.02
const scene = new THREE.Scene()
const skyColor = new THREE.Color(SEASONS[1].sky)
@@ -162,7 +162,7 @@ export function mount(host: HTMLElement): TownHandle {
const CAM_DIR = new THREE.Vector3(0.62, 0.72, 0.86).normalize()
/* 카피가 왼쪽에 앉는 넓은 화면에서는 피사체를 오른쪽으로 민다(거리에 대한 비율) */
let panRatio = 0
- /* 좁은 화면은 종이 판이 아래쪽을 덮으므로 피사체를 위로 올린다(화면 높이에 대한 비율) */
+ /* 좁은 화면은 카드이 아래쪽을 덮으므로 피사체를 위로 올린다(화면 높이에 대한 비율) */
let liftRatio = 0
/* 세로로 긴 화면은 가로 시야가 좁아 같은 거리에서 건물만 꽉 찬다. 거리를 늘려 동네 폭을 맞춘다 */
let distScale = 1
@@ -187,7 +187,7 @@ export function mount(host: HTMLElement): TownHandle {
}
/* ---------------- 조명 ---------------- */
- const hemi = new THREE.HemisphereLight(0xdfeaf2, 0x8a8a70, 0.85)
+ const hemi = new THREE.HemisphereLight(0xdfeaf2, 0xb9c9a8, 0.85)
scene.add(hemi)
const sun = new THREE.DirectionalLight(0xfff6e8, 2.7)
@@ -222,7 +222,7 @@ export function mount(host: HTMLElement): TownHandle {
ground.receiveShadow = true
world.add(ground)
- const asphaltTex = grainTexture(256, '#62666b', '#2a2d30', 0.4)
+ const asphaltTex = grainTexture(256, '#8e9398', '#6d7277', 0.35)
asphaltTex.repeat.set(30, 2)
const asphaltMat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: asphaltTex, roughness: 0.95 })
const asphaltBase = new THREE.Color(0xffffff)
@@ -237,7 +237,7 @@ export function mount(host: HTMLElement): TownHandle {
roadZ.receiveShadow = true
world.add(roadX, roadZ)
- const walkTex = grainTexture(128, '#d2d5cd', '#9aa096', 0.3)
+ const walkTex = grainTexture(128, '#f2f2ef', '#d8d8d4', 0.25)
walkTex.repeat.set(20, 1)
const walkMat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: walkTex, roughness: 0.95 })
const walkBase = new THREE.Color(0xffffff)
@@ -257,7 +257,7 @@ export function mount(host: HTMLElement): TownHandle {
/* 중앙선(노란 겹선)과 횡단보도. 막대 하나하나를 메시로 만들면 드로우콜이 수십 개라 인스턴스로 */
const lineGeo = new THREE.BoxGeometry(1, 0.02, 1)
- const yellowMat = new THREE.MeshStandardMaterial({ color: 0xe6c24a, roughness: 0.7 })
+ const yellowMat = new THREE.MeshStandardMaterial({ color: 0xffe54e, roughness: 0.7 })
const whiteMat = new THREE.MeshStandardMaterial({ color: 0xf1f2ed, roughness: 0.7 })
const tmpM = new THREE.Matrix4()
const tmpQ = new THREE.Quaternion()
@@ -298,8 +298,8 @@ export function mount(host: HTMLElement): TownHandle {
/* ================================================================
건물 — 벽은 상자 하나, 창은 텍스처. 옥상은 공용 재질이라 눈이 한 번에 쌓인다.
================================================================ */
- const roofMat = new THREE.MeshStandardMaterial({ color: 0xb8b3a8, roughness: 0.95 })
- const roofBase = new THREE.Color(0xb8b3a8)
+ const roofMat = new THREE.MeshStandardMaterial({ color: 0xe9e9e4, roughness: 0.95 })
+ const roofBase = new THREE.Color(0xe9e9e4)
type Spec = {
x: number
@@ -355,7 +355,7 @@ export function mount(host: HTMLElement): TownHandle {
const g = new THREE.Group()
const base = s.shopfront ? s.floorH : 0
const upper = s.shopfront ? s.floors - 1 : s.floors
- const walls = wallMaterial(s.wall, s.glass ?? '#7f95a6', s.frame ?? '#cfc6b4')
+ const walls = wallMaterial(s.wall, s.glass ?? '#9fc3dc', s.frame ?? '#e3e3de')
if (upper > 0) {
const h = upper * s.floorH
@@ -366,7 +366,7 @@ export function mount(host: HTMLElement): TownHandle {
}
if (s.shopfront) {
- const shop = wallMaterial(s.shopfront, '#3f5868', '#2f3b44')
+ const shop = wallMaterial(s.shopfront, '#7fa9c6', '#c9d2d8')
const storefront = new THREE.Mesh(wallBox(s.w - 0.3, s.floorH, s.d - 0.3, 1), [shop, roofMat])
storefront.position.set(s.x, s.floorH / 2, s.z)
storefront.castShadow = storefront.receiveShadow = true
@@ -402,13 +402,16 @@ export function mount(host: HTMLElement): TownHandle {
world.add(m)
}
- const SEAL = 0x36aa1c
- const STAMP = 0xc2453d
+ /* 앱 팔레트 — green-600 · red · yellow · blue */
+ const SEAL = 0x4fbe27
+ const STAMP = 0xe55656
+ const YELLOW = 0xffe54e
+ const BLUE = 0x2764be
// 집 — 4층 빌라. 정면(+z)이 보도를 본다
const P = PLACES
- building({ ...P.home, floors: 4, floorH: 2.9, wall: '#ebe2d2', frame: '#cdbfa6' })
- sign(P.home.x, 1.1, P.home.z + P.home.d / 2 + 0.1, 2.2, 2.2, 0x8a7d66) // 현관문
+ building({ ...P.home, floors: 4, floorH: 2.9, wall: '#ffffff', frame: '#e6e2da' })
+ sign(P.home.x, 1.1, P.home.z + P.home.d / 2 + 0.1, 2.2, 2.2, 0xb58a63) // 현관문
{
const canopy = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.14, 1.4), roofMat)
canopy.position.set(P.home.x, 2.3, P.home.z + P.home.d / 2 + 0.7)
@@ -417,14 +420,14 @@ export function mount(host: HTMLElement): TownHandle {
}
// 소아청소년과가 든 모퉁이 상가 — 1층 유리, 2층 간판
- building({ ...P.clinic, floors: 3, floorH: 3.4, wall: '#f1efe9', frame: '#c9ccc6', shopfront: '#e7e9e4' })
+ building({ ...P.clinic, floors: 3, floorH: 3.4, wall: '#f7f7f5', frame: '#dfe3e6', shopfront: '#ffffff' })
sign(P.clinic.x, 4.6, P.clinic.z + P.clinic.d / 2 + 0.1, 6.5, 1.1, 0xffffff)
sign(P.clinic.x - 2.6, 4.6, P.clinic.z + P.clinic.d / 2 + 0.22, 1.0, 0.8, SEAL)
sign(P.clinic.x + P.clinic.w / 2 + 0.1, 4.6, P.clinic.z, 5, 1.1, 0xffffff, true)
// 어린이집 — 2층, 초록 띠, 앞마당과 울타리
- building({ ...P.daycare, floors: 2, floorH: 3.2, wall: '#f6f0e3', frame: '#e3cf9c', trim: SEAL })
- sign(P.daycare.x - 3, 5.2, P.daycare.z + P.daycare.d / 2 + 0.1, 4.2, 0.9, 0xf2b93c)
+ building({ ...P.daycare, floors: 2, floorH: 3.2, wall: '#fffaf0', frame: '#ffe28a', trim: SEAL })
+ sign(P.daycare.x - 3, 5.2, P.daycare.z + P.daycare.d / 2 + 0.1, 4.2, 0.9, YELLOW)
{
const yardZ0 = P.daycare.z + P.daycare.d / 2 // 건물 앞면
const yardZ1 = -6.8 // 울타리
@@ -449,7 +452,7 @@ export function mount(host: HTMLElement): TownHandle {
world.add(rail)
/* 미끄럼틀 — 노랑 발판, 빨강 활주면 */
- const slideBase = new THREE.Mesh(new RoundedBoxGeometry(1.2, 1.4, 1.2, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0xf2b93c, roughness: 0.6 }))
+ const slideBase = new THREE.Mesh(new RoundedBoxGeometry(1.2, 1.4, 1.2, 2, 0.08), new THREE.MeshStandardMaterial({ color: YELLOW, roughness: 0.6 }))
slideBase.position.set(P.daycare.x + 3.8, 0.7, yardZ0 + 1.2)
const slide = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.1, 2.2), new THREE.MeshStandardMaterial({ color: STAMP, roughness: 0.5 }))
slide.position.set(P.daycare.x + 3.8, 0.75, yardZ0 + 2.5)
@@ -463,8 +466,8 @@ export function mount(host: HTMLElement): TownHandle {
}
// 행정복지센터 — 정면(+x)이 z 축 도로를 본다. 국기 게양대
- building({ ...P.center, floors: 2, floorH: 3.6, wall: '#e2ddd2', glass: '#6f8796', frame: '#b8b1a2', trim: 0x9f988a })
- sign(P.center.x + P.center.w / 2 + 0.1, 5.6, P.center.z, 5.5, 0.9, 0x2f5f8f, true)
+ building({ ...P.center, floors: 2, floorH: 3.6, wall: '#f3f4f1', glass: '#9fb9cc', frame: '#d6dad3', trim: 0xd9dcd6 })
+ sign(P.center.x + P.center.w / 2 + 0.1, 5.6, P.center.z, 5.5, 0.9, BLUE, true)
{
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 8.5, 8), new THREE.MeshStandardMaterial({ color: 0xd7d9d6, metalness: 0.4, roughness: 0.4 }))
pole.position.set(-7.2, 4.25, 8.4)
@@ -476,22 +479,24 @@ export function mount(host: HTMLElement): TownHandle {
}
// 배경 아파트와 상가 — 뒤쪽(-z)과 왼쪽(-x)에만 둔다. 앞에 두면 주인공 건물을 가린다
+ /* 배경 건물은 낮고 흰 것만 둔다. 12~14층 탑을 두었더니 주인공 건물을 누르고 동네가
+ 아파트 분양 광고처럼 읽혔다. */
const FILLER: Spec[] = [
- { x: -30, z: -36, w: 16, d: 10, floors: 12, floorH: 2.9, wall: '#e6e4df' },
- { x: -6, z: -38, w: 12, d: 10, floors: 9, floorH: 2.9, wall: '#dcdfdb' },
- { x: 16, z: -36, w: 14, d: 10, floors: 14, floorH: 2.9, wall: '#ece7dc' },
- { x: 40, z: -32, w: 12, d: 10, floors: 8, floorH: 2.9, wall: '#e2e4e0' },
- { x: 40, z: -14, w: 10, d: 10, floors: 4, floorH: 3.1, wall: '#efe8dc', shopfront: '#e4e1d8' },
- { x: -48, z: -12, w: 10, d: 12, floors: 5, floorH: 2.9, wall: '#e9e3d6' },
- { x: -48, z: 16, w: 10, d: 12, floors: 4, floorH: 3.0, wall: '#dfe1dc' },
- { x: -28, z: 32, w: 12, d: 10, floors: 3, floorH: 3.2, wall: '#ece6da', shopfront: '#e2dfd6' },
+ { x: -30, z: -34, w: 16, d: 10, floors: 4, floorH: 2.9, wall: '#ffffff' },
+ { x: -6, z: -36, w: 12, d: 10, floors: 3, floorH: 2.9, wall: '#f6f6f3' },
+ { x: 16, z: -34, w: 14, d: 10, floors: 4, floorH: 2.9, wall: '#ffffff' },
+ { x: 40, z: -32, w: 12, d: 10, floors: 3, floorH: 2.9, wall: '#f4f5f2' },
+ { x: 40, z: -14, w: 10, d: 10, floors: 3, floorH: 3.1, wall: '#ffffff', shopfront: '#fbfbf9' },
+ { x: -48, z: -12, w: 10, d: 12, floors: 3, floorH: 2.9, wall: '#f7f6f2' },
+ { x: -48, z: 16, w: 10, d: 12, floors: 2, floorH: 3.0, wall: '#ffffff' },
+ { x: -28, z: 32, w: 12, d: 10, floors: 2, floorH: 3.2, wall: '#f6f6f3', shopfront: '#ffffff' },
]
FILLER.forEach((s) => building(s))
/* ================================================================
나무 — 은행나무 가로수, 공원의 벚나무, 그리고 태어난 해에 심은 한 그루
================================================================ */
- const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b5a48, roughness: 1 })
+ const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8a6f58, roughness: 1 })
const ginkgoMat = new THREE.MeshStandardMaterial({ color: SEASONS[1].ginkgo, roughness: 0.9 })
const cherryMat = new THREE.MeshStandardMaterial({ color: SEASONS[1].cherry, roughness: 0.9 })
@@ -554,14 +559,14 @@ export function mount(host: HTMLElement): TownHandle {
4월생이라 해마다 생일 무렵에 꽃이 핀다. */
const pathRing = new THREE.Mesh(
new THREE.RingGeometry(4.6, 6.0, 48),
- new THREE.MeshStandardMaterial({ color: 0xdccfb3, roughness: 1 }),
+ new THREE.MeshStandardMaterial({ color: 0xefe6d2, roughness: 1 }),
)
pathRing.rotation.x = -Math.PI / 2
pathRing.position.set(P.park.x, 0.03, P.park.z)
pathRing.receiveShadow = true
world.add(pathRing)
- const benchMat = new THREE.MeshStandardMaterial({ color: 0x8c6a4a, roughness: 0.8 })
+ const benchMat = new THREE.MeshStandardMaterial({ color: 0xc9a47a, roughness: 0.8 })
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 + 0.9
const bench = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.45, 0.5), benchMat)
@@ -602,9 +607,9 @@ export function mount(host: HTMLElement): TownHandle {
}
const family = new THREE.Group()
- const parent = person(0x3f6f8f, 0x39424c, 0.075)
+ const parent = person(SEAL, 0x4a5563, 0.075)
parent.scale.setScalar(ADULT)
- const child = person(0xf2b93c, 0x5d6f86, 0.11)
+ const child = person(YELLOW, BLUE, 0.11)
child.position.set(0.55, 0, 0.1)
/* 책가방 — 취학을 앞둔 해에만 멘다 */
const backpack = new THREE.Mesh(new RoundedBoxGeometry(0.2, 0.24, 0.12, 2, 0.03), new THREE.MeshStandardMaterial({ color: STAMP, roughness: 0.6 }))
@@ -615,7 +620,7 @@ export function mount(host: HTMLElement): TownHandle {
const stroller = new THREE.Group()
{
const frameMat = new THREE.MeshStandardMaterial({ color: 0x2f3439, roughness: 0.6, metalness: 0.3 })
- const basket = new THREE.Mesh(new RoundedBoxGeometry(0.5, 0.36, 0.78, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0x9fb8a2, roughness: 0.8 }))
+ const basket = new THREE.Mesh(new RoundedBoxGeometry(0.5, 0.36, 0.78, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0xd3f1c4, roughness: 0.8 }))
basket.position.set(0, 0.62, 0)
const hood = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), basket.material)
hood.position.set(0, 0.78, -0.2)
@@ -652,7 +657,7 @@ export function mount(host: HTMLElement): TownHandle {
}
const walkerBody = new THREE.InstancedMesh(new THREE.CapsuleGeometry(0.16, 0.6, 4, 8), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.85 }), WALKERS.length)
const walkerHead = new THREE.InstancedMesh(new THREE.SphereGeometry(0.12, 10, 8), skinMat, WALKERS.length)
- const COATS = [0x5b7fa6, 0xc2453d, 0xe0c068, 0x6f8f5a, 0x8b8f96, 0xe8e2d4, 0x39424c]
+ const COATS = [BLUE, STAMP, YELLOW, 0x876bc8, 0x9e9e9e, 0xffffff, SEAL]
WALKERS.forEach((_, i) => walkerBody.setColorAt(i, new THREE.Color(COATS[i % COATS.length])))
walkerBody.castShadow = walkerHead.castShadow = true
world.add(walkerBody, walkerHead)
@@ -661,17 +666,17 @@ export function mount(host: HTMLElement): TownHandle {
type Car = { g: THREE.Group; axis: 'x' | 'z'; lane: number; dir: 1 | -1; start: number; speed: number }
const cars: Car[] = []
const CAR_SPEC: [number, 'x' | 'z', 1 | -1, number, number][] = [
- [0xf2f2ee, 'x', 1, -40, 7],
- [0x3a4450, 'x', -1, 10, 6],
- [0xf2b93c, 'x', 1, 20, 5],
- [0x8d949b, 'z', 1, -20, 6.5],
- [0x2f5f8f, 'z', -1, 35, 5.5],
+ [0xffffff, 'x', 1, -40, 7],
+ [0xbdbdbd, 'x', -1, 10, 6],
+ [YELLOW, 'x', 1, 20, 5],
+ [0xffffff, 'z', 1, -20, 6.5],
+ [BLUE, 'z', -1, 35, 5.5],
]
CAR_SPEC.forEach(([color, axis, dir, start, speed]) => {
const g = new THREE.Group()
const body = new THREE.Mesh(new RoundedBoxGeometry(4.2, 1.0, 1.8, 2, 0.18), new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.2 }))
body.position.y = 0.72
- const cabin = new THREE.Mesh(new RoundedBoxGeometry(2.3, 0.8, 1.6, 2, 0.16), new THREE.MeshStandardMaterial({ color: 0x33414c, roughness: 0.2, metalness: 0.3 }))
+ const cabin = new THREE.Mesh(new RoundedBoxGeometry(2.3, 0.8, 1.6, 2, 0.16), new THREE.MeshStandardMaterial({ color: 0x4a5563, roughness: 0.2, metalness: 0.3 }))
cabin.position.set(-0.25, 1.5, 0)
body.castShadow = cabin.castShadow = true
g.add(body, cabin)
@@ -689,11 +694,16 @@ export function mount(host: HTMLElement): TownHandle {
const pinTip = new THREE.ConeGeometry(0.38, 0.9, 14)
pinTip.rotateX(Math.PI)
pinTip.translate(0, -0.55, 0)
+ const pinDot = new THREE.SphereGeometry(0.24, 14, 10)
+ const pinDotMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.2 })
function pin(x: number, y: number, z: number, place: Place): Pin {
const mat = new THREE.MeshStandardMaterial({ color: SEAL, emissive: SEAL, emissiveIntensity: 0.4, roughness: 0.4 })
const g = new THREE.Group()
- g.add(new THREE.Mesh(pinGeo, mat), new THREE.Mesh(pinTip, mat))
+ /* 가운데 흰 점. 카메라 쪽 면에 박아 지도 앱의 핀처럼 읽히게 한다 */
+ const dot = new THREE.Mesh(pinDot, pinDotMat)
+ dot.position.copy(CAM_DIR).multiplyScalar(0.36)
+ g.add(new THREE.Mesh(pinGeo, mat), new THREE.Mesh(pinTip, mat), dot)
g.position.set(x, y, z)
g.traverse((o) => {
if ((o as THREE.Mesh).isMesh) o.castShadow = true
@@ -709,7 +719,7 @@ export function mount(host: HTMLElement): TownHandle {
]
/* 개월 수에 따라 어느 알림이 살아 있는가. 페이지 본문이 말하는 서비스와 같은 순서다.
- - 행정복지센터: 부모급여(0–23개월). 끝나기 직전에는 마감 도장색
+ - 행정복지센터: 부모급여(0–23개월). 끝나기 직전에는 마감 빨강
- 어린이집: 대기를 거는 무렵부터 유치원으로 넘어가기 전까지
- 소아과: 접종·검진이 계속 온다 */
function pinState(place: Place, m: number): { on: number; deadline: boolean } {
@@ -783,7 +793,7 @@ export function mount(host: HTMLElement): TownHandle {
stroller.scale.setScalar(Math.max(0.0001, 1 - walking))
backpack.visible = m >= 66
- /* 알림 표식 색 — 부모급여가 끝나기 직전에는 도장 빨강 */
+ /* 알림 표식 색 — 부모급여가 끝나기 직전에는 경고 빨강 */
for (const p of pins) {
const st = pinState(p.place, m)
const target = st.deadline ? stampColor : sealColor
@@ -858,7 +868,11 @@ export function mount(host: HTMLElement): TownHandle {
if (shownMonth < 0) shownMonth = targetMonth
const diff = targetMonth - shownMonth
if (Math.abs(diff) > 0.002) {
- shownMonth += diff * (1 - Math.exp(-dt * 4.5))
+ /* 24개월 → 72개월처럼 크게 건너뛸 때 1초 안에 네 해가 지나가면 계절이 번쩍거린다.
+ 초당 24개월을 넘지 않게 막아 "해가 바뀌는" 속도로 보이게 한다. */
+ const step = diff * (1 - Math.exp(-dt * 4.5))
+ const cap = dt * 24
+ shownMonth += Math.max(-cap, Math.min(cap, step))
applyMonth(shownMonth)
} else if (shownMonth !== targetMonth) {
shownMonth = targetMonth
diff --git a/src/styles/globals.css b/src/styles/globals.css
index b0dfc47..159bd21 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -1,10 +1,11 @@
@import 'tailwindcss';
/*
- * 방향: 육아수첩 · 관공서 기록부.
+ * 방향: 앱과 같은 제품.
*
- * 파스텔 베이비 톤(이 분야의 클리셰)도, 핀테크식 무채색도 아니다.
- * 정부가 나눠 주는 아기수첩 — 모눈 인쇄, 개월 수 탭, 도장, 표 — 을 기준으로 삼는다.
+ * 소개 사이트에서 본 것과 앱을 열었을 때의 인상이 같아야 한다. 색·서체·모서리는
+ * CareCode_FE 의 src/styles/globals.css(@theme) 를 그대로 옮긴다 — 흰 바탕, 브랜드 그린,
+ * Pretendard 굵은 표제, 둥근 모서리. 값을 바꿀 때는 앱 쪽이 원본이다.
*/
@font-face {
@@ -15,99 +16,116 @@
}
:root {
- /* 재생지에 가까운 차가운 오프화이트. 흔한 크림색(#F4F1EA)을 피한다. */
- --paper: #f6f8f3;
- --paper-deep: #eef2e9;
- /* 도장 잉크에 가까운 진한 녹흑. 순수 검정은 인쇄물처럼 보이지 않는다. */
- --ink: #1c2620;
- --ink-soft: #4a564d;
- /* 유효·현재. 앱과 같은 브랜드 그린을 쓴다. */
- --seal: #36aa1c;
- --seal-deep: #23791a;
- /* 이미 지나간 것 */
- --past: #a8b2a5;
- /* 마감·경고. 경보 빨강이 아니라 도장 빨강. */
- --stamp: #c2453d;
- /* 모눈선 */
- --rule: #dce3d6;
+ /* 앱 팔레트 — green 50~900 · gray 50~900 */
+ --green-50: #edfae8;
+ --green-100: #d3f1c4;
+ --green-200: #b5e89e;
+ --green-600: #4fbe27;
+ --green-700: #36aa1c;
+ --green-800: #139510;
+ --green-900: #007300;
+ --gray-50: #fafafa;
+ --gray-100: #f5f5f5;
+ --gray-200: #eeeeee;
+ --gray-300: #e0e0e0;
+ --gray-400: #bdbdbd;
+ --gray-500: #9e9e9e;
+ --gray-600: #757575;
+ --gray-700: #616161;
+ --gray-900: #212121;
+ --red: #e55656;
+
+ /* 의미 토큰. 컴포넌트는 이것만 쓴다 */
+ --bg: #ffffff;
+ /* 옅은 면 — 카드 · 표 머리 · 막대 바탕 */
+ --surface: var(--gray-50);
+ --fg: var(--gray-900);
+ --fg-soft: var(--gray-700);
+ /* 유효 · 현재. 면과 버튼은 앱 버튼과 같은 600 */
+ --brand: var(--green-600);
+ /* 초록 글자. 흰 바탕에서 본문 크기로 읽히려면 900 까지 내려야 4.5:1 을 넘는다 */
+ --brand-deep: var(--green-900);
+ --brand-tint: var(--green-50);
+ /* 이미 지나간 것 · 모르는 것 */
+ --muted: var(--gray-500);
+ /* 마감 · 경고 */
+ --alert: var(--red);
+ /* 경고 글자. 앱의 red 는 면·아이콘용이라 흰 바탕 본문 크기에서 대비가 모자란다 */
+ --alert-deep: #b83a3a;
+ --line: var(--gray-200);
+
+ --radius: 16px;
+ --radius-sm: 12px;
+ --shadow: 0 1px 2px rgba(33, 33, 33, 0.04), 0 8px 24px rgba(33, 33, 33, 0.06);
}
@theme inline {
- --color-paper: var(--paper);
- --color-ink: var(--ink);
- --color-seal: var(--seal);
- --font-sans: 'Pretendard', system-ui, sans-serif;
+ --color-bg: var(--bg);
+ --color-fg: var(--fg);
+ --color-brand: var(--brand);
+ --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
}
html {
- background-color: var(--paper);
+ background-color: var(--bg);
}
body {
- background-color: var(--paper);
- color: var(--ink);
- font-family: 'Pretendard', system-ui, sans-serif;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-family: var(--font-sans);
+ letter-spacing: -0.02em;
-webkit-font-smoothing: antialiased;
+ word-break: keep-all;
}
-/* 모눈 종이. 아주 옅게 깔려 배경이 종이라는 것만 알린다. */
-.paper-grid {
- background-image:
- linear-gradient(to right, var(--rule) 1px, transparent 1px),
- linear-gradient(to bottom, var(--rule) 1px, transparent 1px);
- background-size: 28px 28px;
- background-position: -1px -1px;
+::selection {
+ background: var(--green-100);
}
-/*
- * 표제. 굵게 외치는 대신 얇게 크게 놓는다.
- *
- * 이 서비스의 목소리는 "다 받으세요" 가 아니라 "이건 알고 이건 모릅니다" 라서,
- * 700 짜리 고딕 헤드라인은 성격에 맞지 않는다. 한글 명조를 외부에서 받아 오는 방법도 있지만
- * 개인정보 고지를 두는 서비스가 방문자 IP 를 매번 외부로 보낼 수는 없어, 자체 호스팅한
- * Pretendard 의 굵기·크기·자간 대비로 목소리를 만든다.
- */
+/* 표제. 앱의 h1~h3 처럼 굵게 쓴다. 한글은 글자가 상자를 꽉 채워 행간을 라틴보다 넉넉히 둔다 */
.display {
- font-weight: 300;
- letter-spacing: -0.035em;
- line-height: 1.26;
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ line-height: 1.32;
}
-/* 금액·개월·수치. 자릿수가 흔들리지 않아야 표와 축이 읽힌다. */
+/* 금액 · 개월 · 수치. 서체를 갈아끼우지 않고 자릿수만 맞춘다 */
.figure {
- font-family: var(--font-figure), ui-monospace, monospace;
font-variant-numeric: tabular-nums;
+ font-weight: 700;
letter-spacing: -0.02em;
}
-/* 수첩의 항목 라벨. 인쇄된 안내문처럼 작고 조금 벌려 놓는다. */
+/* 항목 라벨 — 제목 위에 얹는 짧은 말 */
.label {
- font-size: 0.6875rem;
- font-weight: 500;
- letter-spacing: 0.08em;
- color: var(--ink-soft);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--fg-soft);
}
-/* 값을 모를 때의 표기. 이 서비스에서 '미상'은 일급 상태다. */
+/* 값을 모를 때의 표기. 이 서비스에서 '미상'은 일급 상태다 */
.unknown {
- font-family: var(--font-figure), ui-monospace, monospace;
- color: var(--past);
+ color: var(--muted);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 0.25em;
}
-/* 도장. 종이 위에 찍힌 것처럼 살짝 기울인다. */
-.stamp {
- font-family: var(--font-figure), ui-monospace, monospace;
- font-size: 0.6875rem;
+/* 상태 배지. 글자색(currentColor)에서 옅은 면을 뽑아 쓴다 — 색 하나만 주면 된다 */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 0.75rem;
font-weight: 600;
- letter-spacing: 0.1em;
- border: 1.5px solid currentColor;
- border-radius: 2px;
- padding: 0.2rem 0.45rem;
- transform: rotate(-3deg);
- display: inline-block;
+ line-height: 1.4;
+ letter-spacing: -0.01em;
+ background: color-mix(in srgb, currentColor 11%, transparent);
+ white-space: nowrap;
}
/* 앵커나 건너뛰기 링크로 이동할 때 제목이 상단 고정 메뉴에 가리지 않게 한다. */
@@ -119,12 +137,12 @@ h3,
}
:focus-visible {
- outline: 2px solid var(--seal);
+ outline: 2px solid var(--green-700);
outline-offset: 3px;
- border-radius: 2px;
+ border-radius: 4px;
}
-/* 스크롤 등장. 한 번만, 조용히. */
+/* 첫 화면 등장. 한 번만, 조용히. */
@keyframes rise {
from {
opacity: 0;
diff --git a/src/styles/grow.css b/src/styles/grow.css
index b0b58c3..2df8710 100644
--- a/src/styles/grow.css
+++ b/src/styles/grow.css
@@ -4,7 +4,7 @@
* 스크롤 진행이 곧 아이의 개월 수다. 그 개월 수가 뒤에 깔린 동네(3D)의 계절·아이의 키·
* 카메라가 보는 건물을 정한다. 이 파일은 그 무대와 구간의 문법만 가진다.
*
- * 색·서체·도장은 globals.css 의 육아수첩 토큰을 그대로 쓴다. 이 파일이 새로 만드는 색은 없다.
+ * 색·모서리·그림자는 globals.css 의 앱 토큰을 그대로 쓴다. 이 파일이 새로 만드는 색은 없다.
* 클래스는 전부 yr- 로 시작한다 — Tailwind 유틸리티 이름(gap, table, grid …)과 부딪히지 않게.
*/
@@ -14,9 +14,6 @@
--rail: 88px;
--shell: min(1180px, 100% - 2 * max(var(--pad), var(--rail)));
--ease: cubic-bezier(0.16, 1, 0.3, 1);
- --grid:
- linear-gradient(to right, var(--rule) 1px, transparent 1px),
- linear-gradient(to bottom, var(--rule) 1px, transparent 1px);
}
@media (max-width: 900px) {
@@ -31,14 +28,6 @@
margin-inline: auto;
}
-/* 한국어는 기본값이면 음절 단위로 잘린다 — 어절로만 끊는다 */
-.yr-station,
-.yr-hero,
-.yr-close {
- word-break: keep-all;
- overflow-wrap: break-word;
-}
-
/* ============================================================
무대 — 동네 캔버스와 스크림
============================================================ */
@@ -49,7 +38,7 @@
opacity: var(--sky-op, 1);
transition: opacity 0.45s linear;
pointer-events: none;
- background: var(--paper);
+ background: var(--bg);
}
.yr-sky canvas {
@@ -57,17 +46,17 @@
height: 100%;
}
-/* WebGL2 가 없거나 동작 줄이기를 켰을 때. 봄 하늘과 잔디 한 줄 — 동네 대신 계절만 남긴다 */
+/* WebGL2 가 없거나 동작 줄이기 · 소프트웨어 렌더러일 때. 동네 대신 봄 하늘과 잔디만 남긴다 */
.yr-sky__fallback {
position: absolute;
inset: 0;
background:
- radial-gradient(70% 50% at 78% 70%, rgba(244, 195, 211, 0.55), transparent 70%),
- radial-gradient(60% 40% at 88% 58%, rgba(157, 195, 124, 0.45), transparent 70%),
- linear-gradient(180deg, #e3edf1 0%, #eef3ee 58%, #d6e6c4 58.2%, #bfd6a6 100%);
+ radial-gradient(60% 45% at 80% 72%, rgba(244, 195, 211, 0.5), transparent 70%),
+ radial-gradient(55% 40% at 70% 62%, rgba(181, 232, 158, 0.55), transparent 70%),
+ linear-gradient(180deg, #eef6fb 0%, #f7fbf4 58%, var(--green-50) 58.2%, var(--green-100) 100%);
}
-/* 카피가 앉는 왼쪽을 종이색으로 누른다. 풍경은 오른쪽에서 산다 */
+/* 카피가 앉는 왼쪽을 흰색으로 누른다. 풍경은 오른쪽에서 산다 */
.yr-scrim {
position: fixed;
inset: 0;
@@ -75,12 +64,12 @@
pointer-events: none;
opacity: var(--sky-op, 1);
transition: opacity 0.45s linear;
- background: linear-gradient(94deg, rgba(246, 248, 243, 0.78) 0%, rgba(246, 248, 243, 0.5) 32%, rgba(246, 248, 243, 0) 58%);
+ background: linear-gradient(94deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.35) 32%, rgba(255, 255, 255, 0) 56%);
}
@media (max-width: 900px) {
.yr-scrim {
- background: linear-gradient(180deg, rgba(246, 248, 243, 0.4) 0%, rgba(246, 248, 243, 0) 45%);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 40%);
}
}
@@ -89,11 +78,10 @@
body:has(.yr-sky) > footer {
position: relative;
z-index: 1;
- background: var(--paper);
}
/* ============================================================
- 개월 레일 — 페이지의 척추. 시간 순서가 정보 자체라서 장식이 아니다
+ 개월 레일 — 지금 아이가 몇 개월인지
============================================================ */
.yr-rail {
position: fixed;
@@ -110,28 +98,31 @@ body:has(.yr-sky) > footer {
pointer-events: none;
}
-/* 한글은 세로로 눕히면 글자가 옆으로 누워 읽히지 않는다. 레일 폭 안에서 가로로 쓴다 */
.yr-rail__now {
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: var(--brand);
+ color: #fff;
font-size: 0.8125rem;
- font-weight: 600;
+ font-weight: 700;
font-variant-numeric: tabular-nums;
- color: var(--ink);
white-space: nowrap;
- text-align: center;
}
.yr-rail__track {
position: relative;
- width: 1px;
+ width: 3px;
flex: 0 1 44vh;
- background: var(--rule);
+ border-radius: 3px;
+ background: var(--gray-200);
}
.yr-rail__fill {
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--rd, 0) * 100%);
- background: var(--seal);
+ border-radius: 3px;
+ background: var(--brand);
}
.yr-rail__ticks {
@@ -141,23 +132,24 @@ body:has(.yr-sky) > footer {
.yr-rail__tick {
position: absolute;
- left: -3px;
- width: 7px;
- height: 1px;
- background: var(--past);
+ left: -2.5px;
+ width: 8px;
+ height: 8px;
+ margin-top: -4px;
+ border-radius: 50%;
+ background: var(--bg);
+ border: 2px solid var(--gray-300);
}
.yr-rail__tick[data-on='1'] {
- background: var(--seal);
- left: -5px;
- width: 11px;
+ border-color: var(--brand);
+ background: var(--brand);
}
.yr-rail__cap {
- font-size: 0.6875rem;
- font-weight: 500;
- letter-spacing: 0.04em;
- color: var(--ink-soft);
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--fg-soft);
white-space: nowrap;
}
@@ -165,24 +157,22 @@ body:has(.yr-sky) > footer {
.yr-rail {
inset: auto 0 0 0;
width: 100%;
- height: 44px;
+ height: 52px;
flex-direction: row;
justify-content: flex-start;
gap: 12px;
padding: 0 var(--pad);
- background: rgba(246, 248, 243, 0.92);
- backdrop-filter: blur(10px);
- border-top: 1px solid var(--rule);
+ background: rgba(255, 255, 255, 0.94);
+ backdrop-filter: blur(12px);
+ border-top: 1px solid var(--line);
}
.yr-rail__now {
order: 3;
- min-width: 4.2em;
- text-align: right;
}
.yr-rail__track {
- height: 1px;
+ height: 3px;
width: auto;
flex: 1 1 auto;
}
@@ -195,20 +185,14 @@ body:has(.yr-sky) > footer {
.yr-rail__tick {
left: auto;
- top: -3px;
- width: 1px;
- height: 7px;
- }
-
- .yr-rail__tick[data-on='1'] {
- top: -5px;
- height: 11px;
- width: 1px;
+ top: -2.5px;
+ margin-top: 0;
+ margin-left: -4px;
}
/* 하단 레일이 푸터 마지막 줄을 가리지 않게 */
body:has(.yr-rail) {
- padding-bottom: 44px;
+ padding-bottom: 52px;
}
}
@@ -218,14 +202,32 @@ body:has(.yr-sky) > footer {
.yr-station {
position: relative;
z-index: 1;
- padding: 132px 0;
- background: var(--grid) -1px -1px / 28px 28px, var(--paper);
- /* 위아래 가장자리를 풀어 둔다. 앞뒤 구간에서 동네가 보일 때 칼자국 대신 풍경이 스며든다.
- 패딩(132px)이 페이드(112px)보다 넓어 글자는 흐려지지 않는다. */
- mask-image: linear-gradient(180deg, transparent 0, #000 112px, #000 calc(100% - 112px), transparent 100%);
+ padding: 128px 0;
+ background: var(--bg);
+ /* 동네가 보이는 구간과 맞닿은 가장자리만 흐린다. 불투명 구간끼리 맞닿은 곳까지 흐리면
+ 그 틈으로 바탕이 비쳐 띠가 생긴다. */
+ --fade-top: 0px;
+ --fade-bottom: 0px;
+ mask-image: linear-gradient(
+ 180deg,
+ transparent 0,
+ #000 var(--fade-top),
+ #000 calc(100% - var(--fade-bottom)),
+ transparent 100%
+ );
+}
+
+.yr-hero + .yr-station,
+.yr-station--open + .yr-station {
+ --fade-top: 112px;
+}
+
+.yr-station:has(+ .yr-station--open),
+.yr-station:has(+ .yr-close) {
+ --fade-bottom: 112px;
}
-/* 동네가 주인공인 구간 — 지면을 비우고 글은 종이 판에 올린다 */
+/* 동네가 주인공인 구간 — 지면을 비우고 글은 카드 위에 올린다 */
.yr-station--open {
background: transparent;
mask-image: none;
@@ -238,10 +240,19 @@ body:has(.yr-sky) > footer {
@media (max-width: 900px) {
.yr-station {
padding: 88px 0;
- mask-image: linear-gradient(180deg, transparent 0, #000 64px, #000 calc(100% - 64px), transparent 100%);
}
- /* 좁은 화면은 판이 화면을 다 덮으므로 풍경이 보일 틈을 위쪽에 둔다 */
+ .yr-hero + .yr-station,
+ .yr-station--open + .yr-station {
+ --fade-top: 64px;
+ }
+
+ .yr-station:has(+ .yr-station--open),
+ .yr-station:has(+ .yr-close) {
+ --fade-bottom: 64px;
+ }
+
+ /* 좁은 화면은 카드가 화면을 다 덮으므로 풍경이 보일 틈을 위쪽에 둔다 */
.yr-station--open {
min-height: 0;
padding: 46svh 0 64px;
@@ -250,63 +261,51 @@ body:has(.yr-sky) > footer {
.yr-head {
display: grid;
- grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
- gap: 36px 56px;
+ grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
+ gap: 32px 64px;
align-items: end;
- margin-bottom: 88px;
+ margin-bottom: 64px;
}
@media (max-width: 900px) {
.yr-head {
grid-template-columns: 1fr;
align-items: start;
- margin-bottom: 56px;
+ margin-bottom: 44px;
}
}
-/* 도장 — 이 구간이 아이의 몇 개월인지. 시퀀스가 정보 자체라 장식이 아니다 */
-.yr-stamp {
- color: var(--seal-deep);
- font-size: 0.75rem;
- margin-bottom: 22px;
-}
-
-/* 도장의 숫자 서체(IBM Plex Mono)에는 한글이 없다. 한글이 대체 서체로 넘어가면서 넓은 자간이
- 글자 사이를 벌려 "6개 월"처럼 읽힌다. 한글이 들어가는 도장은 본문 서체로 찍는다. */
-.yr-station .stamp,
-.yr-hero .stamp,
-.yr-close .stamp {
- font-family: inherit;
- letter-spacing: 0.02em;
- font-variant-numeric: tabular-nums;
+/* 개월 배지 — 이 구간이 아이의 몇 개월인지 */
+.yr-month {
+ color: var(--brand-deep);
+ margin-bottom: 18px;
}
.yr-h2 {
- font-size: clamp(1.9rem, 3.6vw, 2.85rem);
- /* 얇은 표제는 행간을 넉넉히 둬야 두 줄이 한 덩어리로 읽힌다 */
- line-height: 1.28;
+ font-size: clamp(1.75rem, 3.2vw, 2.5rem);
+ line-height: 1.36;
margin: 0;
text-wrap: balance;
}
.yr-h3 {
- font-size: 1.0625rem;
- font-weight: 500;
- letter-spacing: -0.01em;
- margin: 88px 0 24px;
+ font-size: 1.125rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin: 72px 0 20px;
}
.yr-when {
- margin-left: 10px;
- font-size: 0.75rem;
- font-weight: 400;
- color: var(--ink-soft);
+ margin-left: 8px;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--muted);
}
.yr-body {
- color: var(--ink-soft);
+ color: var(--fg-soft);
font-size: 1.0625rem;
- line-height: 1.9;
+ line-height: 1.85;
max-width: 60ch;
}
@@ -317,27 +316,28 @@ body:has(.yr-sky) > footer {
.yr-body strong,
.yr-panel__body strong,
.yr-lede strong {
- color: var(--ink);
- font-weight: 600;
+ color: var(--fg);
+ font-weight: 700;
}
.yr-fine {
- margin-top: 28px;
+ margin-top: 24px;
max-width: 70ch;
font-size: 0.875rem;
- line-height: 1.85;
- color: var(--ink-soft);
+ line-height: 1.8;
+ color: var(--fg-soft);
}
.yr-fine a,
.yr-panel a:not(.yr-btn) {
- color: var(--ink);
+ color: var(--brand-deep);
+ font-weight: 600;
text-decoration: underline;
text-underline-offset: 4px;
}
/* ============================================================
- 히어로 · 종이 판
+ 히어로 · 카드
============================================================ */
.yr-hero {
position: relative;
@@ -355,138 +355,149 @@ body:has(.yr-sky) > footer {
}
}
-/* 풍경 위의 글은 종이 한 장에 올린다. 계절마다 바탕색이 바뀌는 위에서도 대비가 보장된다.
- 카드 그리드가 아니라 구간마다 판 하나 — 수첩을 펼쳐 든 모양이다. */
+/* 풍경 위의 글은 카드 한 장에 올린다. 계절마다 바탕색이 바뀌는 위에서도 대비가 보장된다. */
.yr-panel {
max-width: 600px;
padding: clamp(24px, 3.4vw, 44px);
- background: rgba(246, 248, 243, 0.93);
- backdrop-filter: blur(8px);
- border: 1px solid var(--rule);
- box-shadow: 4px 4px 0 var(--paper-deep);
+ background: rgba(255, 255, 255, 0.96);
+ backdrop-filter: blur(10px);
+ border-radius: 24px;
+ box-shadow: var(--shadow);
}
.yr-panel__body {
- margin-top: 22px;
+ margin-top: 18px;
font-size: 1rem;
- line-height: 1.9;
- color: var(--ink-soft);
+ line-height: 1.85;
+ color: var(--fg-soft);
}
.yr-panel__notice {
- margin: 32px 0 8px;
+ margin: 28px 0 4px;
}
.yr-panel .yr-h3 {
- margin-top: 40px;
+ margin-top: 36px;
}
.yr-display {
- margin-top: 20px;
- font-size: clamp(2.5rem, 5.2vw, 4.2rem);
- line-height: 1.2;
- letter-spacing: -0.04em;
+ margin-top: 18px;
+ font-size: clamp(2.25rem, 4.6vw, 3.6rem);
+ line-height: 1.28;
+ letter-spacing: -0.035em;
+}
+
+/* 형광펜 한 줄 — 이 문장의 핵심 */
+.yr-hl {
+ background: linear-gradient(180deg, transparent 60%, var(--green-100) 60%, var(--green-100) 92%, transparent 92%);
}
.yr-lede {
- margin-top: 26px;
+ margin-top: 22px;
max-width: 44ch;
font-size: clamp(1.02rem, 1.3vw, 1.125rem);
- line-height: 1.85;
- color: var(--ink-soft);
+ line-height: 1.8;
+ color: var(--fg-soft);
}
.yr-hero__cta {
- margin-top: 34px;
+ margin-top: 32px;
display: flex;
flex-wrap: wrap;
- gap: 12px;
+ gap: 10px;
}
+/* 앱의 Button 과 같은 모양 — 둥근 12px, 600 굵기, 초록 600 → 700 */
.yr-btn {
display: inline-flex;
align-items: center;
- min-height: 46px;
- padding: 0 22px;
- font-size: 0.95rem;
+ justify-content: center;
+ min-height: 52px;
+ padding: 0 24px;
+ border-radius: var(--radius-sm);
+ font-size: 1rem;
+ font-weight: 600;
text-decoration: none;
- border: 1px solid var(--ink);
- transition: background-color 0.16s var(--ease), color 0.16s var(--ease);
+ transition: background-color 0.16s var(--ease);
}
.yr-btn--primary {
- background: var(--ink);
- color: var(--paper);
+ background: var(--green-600);
+ color: #fff;
}
.yr-btn--primary:hover {
- background: var(--seal-deep);
- border-color: var(--seal-deep);
+ background: var(--green-700);
}
.yr-btn--ghost {
- background: transparent;
- color: var(--ink);
- border-color: var(--rule);
+ background: var(--gray-100);
+ color: var(--gray-700);
}
.yr-btn--ghost:hover {
- border-color: var(--ink);
+ background: var(--gray-200);
}
.yr-hint {
- margin-top: 28px;
- font-size: 0.75rem;
- letter-spacing: 0.04em;
- color: var(--ink-soft);
+ margin-top: 22px;
+ font-size: 0.8125rem;
+ color: var(--muted);
}
/* ============================================================
- 구조 문법 — 카드가 아니라 규칙선과 행이 구조를 만든다
+ 구간 안의 부품
============================================================ */
/* 마주 놓는 두 단위 (1개월) */
.yr-gap {
- border-top: 1px solid var(--ink);
+ border-radius: var(--radius);
+ border: 1px solid var(--line);
+ overflow: hidden;
}
.yr-gap__row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
- padding: 18px 0;
- border-bottom: 1px solid var(--rule);
+ padding: 18px 24px;
+ border-top: 1px solid var(--line);
align-items: baseline;
}
.yr-gap__head {
- font-size: 0.75rem;
- letter-spacing: 0.06em;
- color: var(--ink-soft);
+ border-top: 0;
+ background: var(--surface);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--fg-soft);
}
.yr-gap__row > span:nth-child(2) {
- color: var(--ink-soft);
+ color: var(--fg-soft);
}
.yr-gap__parent {
- color: var(--ink);
+ color: var(--fg);
+ font-weight: 600;
}
.yr-gap__row:not(.yr-gap__head) .yr-gap__parent::before {
content: '';
display: inline-block;
- width: 7px;
- height: 7px;
+ width: 8px;
+ height: 8px;
margin-right: 10px;
- background: var(--seal);
- vertical-align: 0.15em;
+ border-radius: 50%;
+ background: var(--brand);
+ vertical-align: 0.12em;
}
@media (max-width: 700px) {
.yr-gap__row {
- grid-template-columns: 64px minmax(0, 1fr);
- gap: 6px 16px;
+ grid-template-columns: 56px minmax(0, 1fr);
+ gap: 6px 14px;
+ padding: 14px 16px;
}
.yr-gap__row > span:nth-child(3) {
@@ -502,62 +513,54 @@ body:has(.yr-sky) > footer {
}
}
-/* 수치 한 줄 */
+/* 수치 카드 */
.yr-figures {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 32px 40px;
+ gap: 12px;
list-style: none;
padding: 0;
margin: 0;
}
-.yr-figures--3 {
- grid-template-columns: repeat(3, minmax(0, 1fr));
-}
-
.yr-figures li {
- border-top: 1px solid var(--rule);
- padding-top: 16px;
+ padding: 20px;
+ border-radius: var(--radius);
+ background: var(--surface);
}
.yr-figures__v {
- font-size: 1.85rem;
+ font-size: 1.875rem;
line-height: 1;
- color: var(--ink);
+ color: var(--fg);
}
.yr-figures__v small {
- margin-left: 4px;
- font-size: 0.9rem;
- color: var(--ink-soft);
+ margin-left: 3px;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--fg-soft);
}
.yr-figures__k {
margin-top: 12px;
font-size: 0.9375rem;
+ font-weight: 600;
}
.yr-figures__n {
- margin-top: 4px;
+ margin-top: 2px;
font-size: 0.8125rem;
- line-height: 1.7;
- color: var(--ink-soft);
+ line-height: 1.6;
+ color: var(--fg-soft);
}
@media (max-width: 900px) {
- .yr-figures,
- .yr-figures--3 {
+ .yr-figures {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
-@media (max-width: 480px) {
- .yr-figures--3 {
- grid-template-columns: 1fr;
- }
-}
-
/* 조건 → 이유 두 칸 */
.yr-rows {
margin: 0;
@@ -565,86 +568,87 @@ body:has(.yr-sky) > footer {
.yr-rows__row {
display: grid;
- grid-template-columns: 180px minmax(0, 1fr);
- gap: 6px 24px;
+ grid-template-columns: 170px minmax(0, 1fr);
+ gap: 6px 20px;
padding: 14px 0;
- border-top: 1px solid var(--rule);
+ border-top: 1px solid var(--line);
font-size: 0.9375rem;
- line-height: 1.75;
-}
-
-.yr-rows--wide .yr-rows__row {
- grid-template-columns: 200px minmax(0, 1fr);
- padding: 20px 0;
- font-size: 1rem;
+ line-height: 1.7;
}
.yr-rows dt {
- color: var(--ink);
+ color: var(--fg);
+ font-weight: 600;
}
.yr-rows dd {
margin: 0;
- color: var(--ink-soft);
+ color: var(--fg-soft);
}
@media (max-width: 620px) {
- .yr-rows__row,
- .yr-rows--wide .yr-rows__row {
+ .yr-rows__row {
grid-template-columns: 1fr;
}
}
-/* 월령 타임라인을 올려 두는 판 (12개월) */
+/* 월령 타임라인을 올려 두는 카드 (12개월) */
.yr-play {
- border: 1px solid var(--rule);
- background: var(--paper);
- box-shadow: 4px 4px 0 var(--paper-deep);
+ border-radius: 24px;
+ border: 1px solid var(--line);
+ background: var(--bg);
+ box-shadow: var(--shadow);
padding: clamp(22px, 3.4vw, 40px);
}
-/* 고친 기록 원장 (24개월). 안 바뀐 것도 결정이지만, 여기서는 원인이 곧 설명이다 */
+/* 고친 기록 (24개월) — 원인이 곧 설명이다 */
.yr-ledger {
- border-top: 1px solid var(--ink);
+ border-radius: var(--radius);
+ border: 1px solid var(--line);
+ overflow: hidden;
}
.yr-ledger__row {
display: grid;
- grid-template-columns: 120px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr);
- gap: 24px;
- padding: 18px 0;
- border-bottom: 1px solid var(--rule);
+ grid-template-columns: 110px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr);
+ gap: 20px;
+ padding: 16px 24px;
+ border-top: 1px solid var(--line);
align-items: baseline;
font-size: 0.9375rem;
}
.yr-ledger__head {
- font-size: 0.75rem;
- letter-spacing: 0.06em;
- color: var(--ink-soft);
+ border-top: 0;
+ background: var(--surface);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--fg-soft);
}
.yr-ledger__n {
- color: var(--ink);
+ color: var(--fg);
+ font-weight: 600;
}
.yr-ledger__a {
- color: var(--past);
+ color: var(--muted);
}
.yr-ledger__b {
- color: var(--seal-deep);
+ color: var(--brand-deep);
+ font-weight: 600;
}
.yr-ledger__d {
- color: var(--ink-soft);
- line-height: 1.75;
+ color: var(--fg-soft);
+ line-height: 1.7;
}
.yr-ledger__total {
- border-top: 1px solid var(--ink);
- border-bottom: 0;
- padding-top: 22px;
+ background: var(--brand-tint);
+ padding-top: 20px;
+ padding-bottom: 20px;
}
.yr-ledger__total .yr-ledger__a {
@@ -652,7 +656,7 @@ body:has(.yr-sky) > footer {
}
.yr-ledger__total .figure {
- font-size: 1.15rem;
+ font-size: 1.125rem;
}
@media (max-width: 760px) {
@@ -663,6 +667,11 @@ body:has(.yr-sky) > footer {
.yr-ledger__row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 4px 16px;
+ padding: 16px;
+ }
+
+ .yr-ledger__row:nth-child(2) {
+ border-top: 0;
}
.yr-ledger__n,
@@ -681,105 +690,33 @@ body:has(.yr-sky) > footer {
}
}
-/* 세 갈래 판정 — 왼쪽 색 띠가 결과의 종류를 말한다 */
+/* 세 갈래 판정 */
.yr-levels {
list-style: none;
margin: 0;
padding: 0;
display: grid;
- gap: 1px;
- background: var(--rule);
- border: 1px solid var(--rule);
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
}
.yr-levels li {
- display: grid;
- grid-template-columns: 132px minmax(0, 1fr);
- gap: 16px 28px;
- align-items: baseline;
- padding: 22px 24px 22px 21px;
- background: var(--paper);
- border-left: 3px solid var(--c);
+ padding: 22px;
+ border-radius: var(--radius);
+ background: var(--surface);
}
.yr-levels p {
- color: var(--ink-soft);
+ margin-top: 12px;
+ color: var(--fg-soft);
font-size: 0.9375rem;
- line-height: 1.8;
-}
-
-@media (max-width: 620px) {
- .yr-levels li {
- grid-template-columns: 1fr;
- }
-}
-
-/* 아는 것 / 모르는 것 (36개월) — 못 하는 것은 각주가 아니라 정식 구간이다 */
-.yr-cannots {
- display: grid;
- gap: 1px;
- background: var(--rule);
- border: 1px solid var(--rule);
-}
-
-.yr-cannots__row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 40px;
- padding: 26px 28px;
- background: var(--paper);
-}
-
-.yr-cannots__row > div + div {
- border-left: 1px solid var(--rule);
- padding-left: 40px;
-}
-
-.yr-cannots__v {
- margin-top: 10px;
- font-size: 1rem;
- line-height: 1.7;
-}
-
-.yr-cannots__why {
- margin-top: 10px;
- font-size: 0.875rem;
- line-height: 1.8;
- color: var(--ink-soft);
+ line-height: 1.75;
}
@media (max-width: 760px) {
- .yr-cannots__row {
+ .yr-levels {
grid-template-columns: 1fr;
- gap: 20px;
- padding: 22px;
}
-
- .yr-cannots__row > div + div {
- border-left: 0;
- padding-left: 0;
- border-top: 1px solid var(--rule);
- padding-top: 20px;
- }
-}
-
-/* 기록에서 나온 문장 (48개월) */
-.yr-quote {
- margin: 64px 0 0;
- max-width: 62ch;
-}
-
-.yr-quote blockquote {
- margin: 0;
- padding: 4px 0 4px 22px;
- border-left: 2px solid var(--seal);
- font-size: 1.0625rem;
- line-height: 1.9;
-}
-
-.yr-quote figcaption {
- margin-top: 14px;
- padding-left: 24px;
}
/* ============================================================
@@ -791,52 +728,81 @@ body:has(.yr-sky) > footer {
min-height: 100svh;
display: grid;
align-content: center;
- padding: 96px 0;
-}
-
-.yr-close__grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 360px);
- gap: 48px;
- align-items: end;
+ padding: 112px 0;
}
.yr-close__title {
- font-size: clamp(2rem, 4vw, 3.1rem);
- line-height: 1.3;
- letter-spacing: -0.035em;
+ font-size: clamp(1.9rem, 3.6vw, 2.75rem);
+ line-height: 1.34;
margin: 0;
text-wrap: balance;
}
-.yr-docs {
+.yr-principles {
+ margin: 20px 0 0;
+ padding: 0;
+ list-style: none;
display: grid;
- gap: 1px;
- background: var(--rule);
- border: 1px solid var(--rule);
- box-shadow: 4px 4px 0 var(--paper-deep);
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
}
-.yr-doc {
+.yr-principle {
display: flex;
- justify-content: space-between;
- gap: 16px;
- padding: 15px 18px;
- background: rgba(246, 248, 243, 0.94);
- backdrop-filter: blur(8px);
- color: var(--ink);
- font-size: 0.9375rem;
+ flex-direction: column;
+ height: 100%;
+ padding: 22px;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.96);
+ backdrop-filter: blur(10px);
+ box-shadow: var(--shadow);
+ color: var(--fg);
text-decoration: none;
- transition: background-color 0.16s var(--ease);
+ transition: transform 0.18s var(--ease), box-shadow 0.18s var(--ease);
}
-.yr-doc:hover {
- background: var(--paper-deep);
+.yr-principle:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 1px 2px rgba(33, 33, 33, 0.05), 0 14px 32px rgba(33, 33, 33, 0.1);
}
-@media (max-width: 980px) {
- .yr-close__grid {
- grid-template-columns: 1fr;
+.yr-principle__t {
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.yr-principle__b {
+ margin-top: 8px;
+ flex: 1;
+ font-size: 0.875rem;
+ line-height: 1.7;
+ color: var(--fg-soft);
+}
+
+.yr-principle__more {
+ margin-top: 14px;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--brand-deep);
+}
+
+.yr-close__repo {
+ display: inline-block;
+ padding: 10px 16px;
+ /* 알약(999px)으로 두면 좁은 화면에서 두 줄로 꺾일 때 모양이 찌그러진다 */
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.yr-close__repo a {
+ color: var(--brand-deep);
+ font-weight: 600;
+}
+
+@media (max-width: 1080px) {
+ .yr-principles {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -847,6 +813,12 @@ body:has(.yr-sky) > footer {
}
}
+@media (max-width: 560px) {
+ .yr-principles {
+ grid-template-columns: 1fr;
+ }
+}
+
/* ============================================================
모션 — 여섯 해가 지나가는 것이 유일한 연출이다. 나머지는 한 가지 등장뿐
============================================================ */