Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/site-kit-single-language-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@devslab/site-kit": minor
---

Header and footer options for a single-language landing and a footer that prints business details (D-029). The new props are optional:

- `SiteHeader` `locale` is optional; without it no language picker renders.
- `SiteBrand.label` names the header brand link; a wordmark `logo` with `name: ""` prints once and leaves no empty `<strong>` in the footer.
- `SiteFooter` `details` (a block under the brand line) and `linksLabel` (wraps the links in `<nav aria-label>`); `SiteLink.emphasis` draws a link heavier.
- Sections and the hero stop below the sticky header (`scroll-margin-block-start`, header height `--site-header-block-size`); `--site-hero-eyebrow-tracking` and `--site-hero-eyebrow-weight` tune the hero eyebrow.
- The header and footer read `brand` (and the footer `details`) once, so inline JSX stays one build per side outside the shell too.

Applies to every consumer:

- The narrow-screen menu closes on Escape (focus back on the menu button) and when a link inside it is followed. Buttons, links that open a new tab or window, and an Escape handled by a control inside the header or an `aria-modal` element leave it open.
- On touch, the brand link and header, footer and footer-language links are 44px targets.
- At 720px and below the closed header's first row is 64px with no block padding: phone headers of products without a global `box-sizing: border-box` reset (TraceLinq, BookLinq) get 24px shorter (89px to 65px), and products with a 44px menu button 4px shorter. The open menu is 44px link rows with 12px under the controls. Check the phone header when upgrading.
- Footer links are baseline-aligned; a footer row with details aligns on its first line. The 16px footer mark size now applies only beside a printed name.

Type change: `SiteHeaderProps["locale"]` is now optional. Code that reads it from a `SiteHeaderProps` value must narrow it (BookLinq's `MarketingFrame` reads `props.header.locale.locale`).
13 changes: 13 additions & 0 deletions docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ D-025. VisionLinq가 `DataTable`로 옮긴 뒤에도 `product-shell.css`에 남
dense 복사 버튼 규칙 삭제(`.dds-table .console-id`는 유지), 유닛·하네스·1440/390
스크린샷 확인.

### 16. site-kit 한 언어 랜딩·사업자 정보 바닥글 — `완료` (2026-09-24)
D-029. FM덴탈서비스 랜딩 리뷰에서 kit으로 못 그리는 9건을 선택 props로:
- [x] 헤더 `locale` 선택, 브랜드 링크 이름(`SiteBrand.label`), 빈 이름이면 푸터 `<strong>` 없음.
- [x] 좁은 화면 메뉴: Esc로 닫고 버튼으로 포커스, 링크를 따라가면 닫힘(버튼은 유지, 안쪽이 처리한 Esc는 무시).
- [x] 터치 44px 링크, 720px 이하 메뉴 44px 줄, 닫힌 좁은 헤더 첫 줄 64px.
- [x] 섹션 `scroll-margin`(`--site-header-block-size`), 히어로 키커 자간·굵기 사용자 속성.
- [x] 푸터 `details`·`linksLabel`, `SiteLink.emphasis`.
- [x] 헤더·푸터가 `brand`·`details`를 한 번만 읽음 — 개발 빌드 하이드레이션 테스트로 고정.
- [x] 테스트: 컴포넌트 16개(옛 코드에서 실패 확인), 소스 계약, 개발 빌드 하이드레이션(메모 제거 시 실패 확인), 브라우저 기하 마우스·터치·터치 넓은 화면(main CSS에서 실패 확인).
- [x] 사전 리뷰(5개 관점, 확인된 지적 15건) 반영: 브랜드 링크 44px, 새 탭·수정 키·`aria-modal` Esc에서 메뉴 유지, 푸터 기준선 정렬, details 여백, 워드마크 로고 크기, 모든 소비자 기본값·타입 변경 문서화.
- **다음(BookLinq·TraceLinq, 올릴 때)**: 휴대폰 헤더 89px → 65px — 375px 시각 확인. BookLinq는 `MarketingFrame`의 `SiteHeaderProps`를 `& { locale: LocaleState }`로 좁혀야 타입 검사가 통과.
- **다음(fm-dental, 별도 세션)**: 랜딩 구현 때 이 버전을 쓴다 — 워드마크 `logo` + `name: ""` + `label`, `locale` 없음, `details`에 사업자 정보·주소, `linksLabel`, 개인정보처리방침 `emphasis`, 랜딩 뿌리에 `--site-hero-eyebrow-tracking: 0`.

---

## P3 — 모바일 이후
Expand Down
42 changes: 42 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@

---

## D-029 — 한 언어 랜딩과 사업자 정보 바닥글: 헤더·푸터 선택 props 9가지 (2026-09-24)

**결정.** `SiteHeader`·`SiteFooter`에 선택 옵션을 더한다. 아무것도 넘기지 않는 소비자는 전과 같은 마크업을 받는다(모든 소비자에 적용되는 기본값 변화는 트레이드오프에).

1. `SiteHeader.locale`을 선택으로 — 없으면 언어 메뉴를 그리지 않는다(푸터 `locale`과 같은 `<Show>`).
2. `SiteBrand.label` → 헤더 브랜드 링크의 `aria-label`. 워드마크를 `logo`로 넘기고 `name: ""`이면 이름이 두 번 찍히지 않고, 푸터도 빈 `<strong>`을 그리지 않는다.
3. 좁은 화면 메뉴: Esc로 닫고 메뉴 버튼으로 포커스 복귀, 안의 링크를 따라가면 닫힘(같은 페이지 앵커 포함). 버튼(테마 전환)과 새 탭·새 창으로 여는 링크(`target`·수정 키)는 닫지 않는다. 헤더 안 요소의 핸들러가 이미 처리한 Esc(`defaultPrevented` — 국기 메뉴)와 `aria-modal` 요소 안에서 온 Esc(문서에 리스너를 다는 DDS Dialog는 이 핸들러 뒤에 돈다)는 그 컨트롤에 맡긴다.
4. 터치(`pointer: coarse`)에서 브랜드 링크·헤더·푸터·푸터 언어 링크 44px, 720px 이하 열린 메뉴의 링크는 44px 줄(간격 0 — 16px 간격의 글줄과 거의 같은 피치), 닫힌 좁은 헤더의 첫 줄은 버튼 크기와 무관하게 64px.
5. 붙는 헤더 아래에 섹션이 멈추게: `.site-hero, .site-section { scroll-margin-block-start: calc(var(--site-header-block-size, 64px) + space-8) }`. 헤더 높이는 사용자 속성 하나(`--site-header-block-size`, 기본 64px).
6. 히어로 키커의 자간·굵기를 사용자 속성으로: `--site-hero-eyebrow-tracking`(기본 `.18em`), `--site-hero-eyebrow-weight`(기본 `normal`).
7. `SiteFooter.details`(JSX) — 브랜드 줄 아래 블록(`.site-footer__identity` > `.site-footer__details`, 줄 간격 4px·자식 여백 0). 이때 행은 첫 줄 기준선 정렬(`first baseline`), 푸터 링크 줄은 기준선 정렬 — 터치에서 44px 링크의 글자가 브랜드·저작권 글자와 같은 높이. 푸터 로고의 16px 크기는 이름(`<strong>`) 옆 아이콘 마크에만(`:has(> strong)`) — `name: ""` 워드마크는 자기 크기.
8. `SiteFooter.linksLabel` — 링크 목록을 `<nav aria-label>`로 감싼다.
9. `SiteLink.emphasis` — 링크를 굵게(700, `text-primary`). 헤더 내비·푸터 링크·패밀리 링크 모두.

그리고 헤더·푸터가 `brand`(푸터는 `details`도)를 `createMemo`로 한 번만 읽는다 — 셸 밖에서 직접 마운트해도 D-027·D-028의 규칙이 지켜지게.

**계기.** FM덴탈서비스(부산 치과기공물 수거·배송, 가족 밖 첫 site-kit 소비자) 랜딩의 디자인 리뷰가 확인한 36건 중 9건이 "지금 kit으로는 그릴 수 없음"이었다. 한국어만 쓰는데 헤더가 언어 메뉴를 늘 그리고(`locale` 필수), 워드마크 링크에 이름을 줄 방법이 없고, 좁은 화면 메뉴가 Esc·링크 클릭에 닫히지 않아 `#contact`로 가도 열린 메뉴가 섹션을 가렸다. 바닥글에는 사업자 정보·주소를 둘 자리가 없고(브랜드 줄은 인라인 `<p>`라 `<address>`를 넣을 수 없음), 링크 묶음에 이름을 줄 수 없고, 개인정보처리방침을 다른 링크와 구분해 눈에 띄게 할 수 없었다(한국 사이트에서 요구되는 표시). 한글 키커에 `.18em` 모노 자간이 그대로 걸렸다.

**근거.**
- props는 **선택·가산**이다: 기존 소비자(VisionLinq·AskLinq·BookLinq·TraceLinq)의 마크업은 바뀌지 않는다 — `locale`을 넘기면 메뉴가 그대로 그려지고, `details`·`linksLabel`이 없으면 행 구조(`.site-footer__row > .site-footer__brand` + `ul`)도 그대로다. 테스트가 이 두 경로를 회귀 가드로 고정한다. 기하·동작 기본값은 모든 소비자에 적용된다(트레이드오프).
- 메뉴 닫힘은 모든 소비자의 결함이었다 — 같은 페이지 앵커 내비게이션은 문서를 바꾸지 않아 열린 메뉴가 남는다. 링크에서만 닫고 버튼에서는 두는 것은 "독자가 어딘가로 갔는가"로 가른 것.
- 44px은 스펙 §6의 모바일 최소선이고 버튼은 이미 `button.css`의 거친 포인터 규칙으로 44px이다(D-025와 같은 원리: 터치에서는 하한이 이긴다). 좁은 화면 메뉴 줄은 포인터와 무관하게 44px — 줄 목록이라 간격 16px의 글줄(약 40px 피치)과 시각 변화가 거의 없다.
- 키커 자간은 `:lang(ko)` 규칙이 아니라 사용자 속성으로 열었다: 한국어 키커를 쓰는 다른 가족 제품(AskLinq·VisionLinq)의 현재 모습을 이 결정이 바꾸지 않게. 각 제품이 자기 랜딩 뿌리에서 정한다.
- `details`·`brand`를 메모로 한 번 읽는 것은 D-027(셸)·D-028(StatusBanner)의 "kit은 받은 JSX를 한 번만 읽는다"를 헤더·푸터 자체로 넓힌 것 — `tests/site-kit-landing-chrome-hydration.test.mjs`가 게터 props로 직접 마운트한 헤더·푸터를 **개발 빌드**로 하이드레이션해 예외 0·잃은 키 0을 고정한다(메모를 직접 읽기로 바꾸면 `Hydration Mismatch`로 실패함을 확인).

**반려한 대안.**
- **FM이 자기 헤더·푸터를 그림** — 가족 셸을 벗어나면 D-027의 하이드레이션 규율·국기 메뉴·스킵 링크를 다시 구현하게 되고, 메뉴 닫힘 같은 전 소비자 결함은 kit에 남는다.
- **`messages`에 `footerLinksLabel` 키 추가** — 필수 키라 모든 소비자의 카탈로그가 깨진다(런타임 폴백은 금지 규칙). 선택 prop으로.
- **한글 키커 자간을 `html:lang(ko)`로 0** — 타이포로는 맞지만 다른 제품의 현재 랜딩을 소리 없이 바꾼다. 필요해지면 그 제품들과 함께 기본값을 바꾼다.
- **`SiteLink.emphasis`를 `<strong>`으로** — 링크 안 강조 요소는 이름에 섞여 읽힐 뿐 뜻을 더하지 않는다. 클래스(`site-link--emphasis`)로 모양만.

**트레이드오프.** 모든 소비자에 적용되는 기본값이 있다 — 사전 리뷰가 소비자별로 잰 값:
- 좁은 화면의 닫힌 헤더는 위아래 패딩 대신 첫 줄 높이(64px)로 만든다. `.site-header__inner`는 dds 클래스가 아니라 전역 `box-sizing: border-box` 리셋이 없는 제품에서는 content-box라, 휴대폰 헤더가 **89px → 65px**(TraceLinq·BookLinq, 이전엔 64px 최소 높이 + 위아래 12px 패딩), 메뉴 버튼이 44px인 제품은 69px → 65px(AskLinq 터치, VisionLinq). 데스크톱(65px)과 같아지고 스크롤 간격도 맞게 되지만, 두 제품의 휴대폰 모양이 요청 없이 바뀐다 — 올릴 때 375px 시각 확인이 필요하다. 64px보다 큰 로고를 쓰는 소비자는 `--site-header-block-size`를 올리고, **`:root`나 `.site-shell`에 정해야** 섹션 간격까지 따라온다(`.site-header`에 정하면 헤더만 바뀜).
- 열린 메뉴: 링크는 간격 0의 44px 줄, 컨트롤 줄 아래 12px(이전엔 헤더 전체 패딩). 첫 링크가 헤더 윗변에서 조금 더 내려간다.
- 터치 기기에서 가족 전 제품의 브랜드 링크·바닥 링크가 44px 누르는 면(이전엔 글줄 높이) — 바닥 링크 줄이 높아진다. 브랜드 링크는 inline에서 inline-flex(가운데 정렬)로.
- 메뉴가 Esc·링크 따라가기로 닫힌다(전 소비자의 결함 수정).
- **타입 변경**: `SiteHeaderProps["locale"]`이 선택이 되어, 그 값을 `SiteHeaderProps`에서 읽는 코드는 좁혀야 한다 — BookLinq `MarketingFrame`의 `props.header.locale.locale`이 `tsc --strict`에서 TS18048. 필수로 두고 컴포넌트 인자만 넓히는 안은 FM이 셸의 `header`로 `locale`을 빼고 넘길 수 없어 반려; 대신 changeset에 적고 BookLinq는 올릴 때 `SiteHeaderProps & { locale: LocaleState }`로 좁힌다(늘 넘기므로 정확). 브라우저 기하는 `tests/browser/site-kit.spec.ts`가 마우스·터치 두 포인터로 고정한다(main의 CSS로 돌리면 5개 실패 확인).

**재검토 시점.** 한국어 키커를 쓰는 두 번째 제품이 자간 0을 원할 때(그때 `:lang(ko)` 기본값), 헤더가 두 줄 이상 높이를 가져야 하는 소비자가 나올 때, 또는 TraceLinq·BookLinq가 휴대폰 헤더의 이전 높이를 원할 때(그때 패딩 경로를 옵션으로).

---

## D-028 — 부품은 `children`도 한 번만 읽는다: `StatusBanner`의 이중 읽기 (2026-09-16)

**결정.** `StatusBanner`가 `props.children`을 `createMemo`로 한 번 읽어, 진위 검사와
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"verify:table:a11y": "pnpm --filter @devslab/dds-table run test:a11y",
"verify:table:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-table run build && node scripts/verify-table-release.mjs",
"verify:site-kit:i18n": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:ui": "node --test tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs && pnpm --filter @devslab/site-kit run test && pnpm --filter @devslab/site-kit run check && pnpm --filter @devslab/site-kit run build && node --test tests/site-kit-hydration.test.mjs tests/site-kit-status-banner-hydration.test.mjs && pnpm --filter @devslab/site-kit run test:worker",
"verify:site-kit:ui": "node --test tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs && pnpm --filter @devslab/site-kit run test && pnpm --filter @devslab/site-kit run check && pnpm --filter @devslab/site-kit run build && node --test tests/site-kit-hydration.test.mjs tests/site-kit-status-banner-hydration.test.mjs tests/site-kit-landing-chrome-hydration.test.mjs && pnpm --filter @devslab/site-kit run test:worker",
"verify:site-kit:seo": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:browser": "playwright test --config playwright.site-kit.config.ts",
"verify:site-kit:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-solid run build && pnpm --filter @devslab/site-kit run build && node scripts/verify-site-kit-release.mjs",
Expand Down
41 changes: 41 additions & 0 deletions packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.

`MarketingShell`은 아무것도 렌더하기 전에 `header`·`footer`를 한 번(메모) 읽는다. `header={{ … }}`는 게터로 컴파일되므로 셸이 prop마다 다시 읽으면 리터럴 안에서 즉시 만들어진 JSX(`actions` 앵커, 로고)가 읽을 때마다 다시 만들어져 하이드레이션 키를 소모하고 — 서버와 브라우저의 횟수가 다르다 — 클라이언트는 헤더를 템플릿에서 다시 만든다. 셸 밖에서 `SiteHeader`·`SiteFooter`를 직접 마운트하는 제품은 같은 방식으로 자기 props를 한 번만 읽어야 한다. 국기 스프라이트는 이유가 무엇이든 클라이언트에 빈 채로 도착하면 본문을 로드한다.

헤더와 푸터는 `brand`(푸터는 `details`도)를 스스로 한 번만 읽는다. 그래서 셸 밖에서도 인라인으로 만든 로고·details 블록이 서버와 브라우저에서 각각 한 번씩만 만들어진다(D-029).

## 헤더·푸터 옵션

한 언어만 쓰는 제품과, 사업자 정보를 바닥에 적어야 하는 제품을 위한 옵션(D-029). props는 모두 선택이고, 아무것도 넘기지 않는 제품은 전과 같은 마크업을 받는다. 다만 모든 제품에 적용되는 기본값 몇 가지가 있다(옵션 목록 뒤).

```tsx
<MarketingShell
mainWidth="bleed"
header={{
brand: { name: "", href: "/", label: "Acme 첫 화면", logo: <Wordmark /> }, // 워드마크가 로고 — 이름이 두 번 찍히지 않게 name ""
navigation: [{ href: "#how", label: "이용 방법" }, { href: "#contact", label: "문의" }],
messages,
get actions() { return <a class="dds-btn dds-btn--primary" href="#contact">이용 문의</a>; },
// locale 없음: 언어 메뉴 없음
}}
footer={{
brand: { name: "", href: "/", logo: <Wordmark /> },
get details() { return <><p>상호 Acme · 사업자등록번호 000-00-00000</p><address>서울 …</address></>; },
linksLabel: "바닥 메뉴",
links: [{ href: "/terms", label: "이용약관" }, { href: "/privacy", label: "개인정보처리방침", emphasis: true }],
copyright: "© Acme",
messages,
}}
messages={messages}
>…</MarketingShell>
```

- `SiteHeader`의 `locale`은 선택이다. 넘기지 않으면 언어 메뉴를 그리지 않는다 — 언어가 하나뿐인 메뉴는 고장 난 것처럼 보인다. 푸터의 `locale`은 원래 이렇게 동작했다.
- `SiteBrand.label`은 헤더 브랜드 링크의 이름(`aria-label`)이다. 화면에 보이는 이름으로 시작한다. 워드마크를 `logo`로 넘기면 `name: ""` — 그러면 푸터도 빈 `<strong>`을 그리지 않는다.
- 좁은 화면의 메뉴는 Esc로 닫히고(포커스는 메뉴 버튼으로 돌아감), 안의 링크를 따라가도 닫힌다 — 같은 페이지 앵커도. 안 그러면 열린 메뉴가 방금 고른 섹션을 가린다. 안의 버튼(테마 전환)과 새 탭·새 창으로 여는 링크는 메뉴를 닫지 않는다. 헤더 안의 컨트롤(국기 메뉴)이 이미 처리한 Esc와 `aria-modal` 요소 안에서 온 Esc는 그 컨트롤에 맡긴다.
- 터치(`pointer: coarse`): 브랜드 링크·내비게이션·푸터·푸터 언어 링크가 버튼처럼 44px 누르는 면이다. 720px 이하에서 열린 메뉴의 링크는 44px 줄이고, 닫힌 헤더의 첫 줄은 64px 그대로다.
- `SiteFooter`의 `details`: 브랜드 줄 아래 블록(사업자 정보, `<address>`), 줄 간격 4px·문단 여백 없음. `linksLabel`은 링크를 `<nav aria-label>`로 감싼다 — 헤더 내비게이션과 다른 이름으로. `logo`로 넘긴 푸터 워드마크(`name: ""`)는 자기 크기를 지킨다 — 16px은 이름 옆 아이콘 마크용 — 그리고 접근 가능한 이름을 스스로 가진다(`<img alt>`나 글자). `label`은 헤더 링크의 이름일 뿐이다.
- `SiteLink.emphasis`는 링크를 굵게 그린다(헤더 내비게이션·푸터 링크·패밀리 링크). 법이 눈에 띄게 하라는 개인정보처리방침용.
- 섹션(그리고 히어로)은 같은 페이지 링크로 이동하면 붙어 있는 헤더 아래에 멈춘다: `scroll-margin-block-start` = 헤더 높이 + 8px. 헤더 높이는 `--site-header-block-size`(기본 64px) — `:root`나 `.site-shell`(헤더와 `<main>`의 공통 조상)에 정한다. `.site-header`에 정하면 섹션은 64px 간격 그대로다.
- `--site-hero-eyebrow-tracking`(기본 `.18em`)과 `--site-hero-eyebrow-weight`(기본 `normal`)로 히어로 키커를 조정한다. 넓은 모노 자간은 라틴 대문자에 맞고, 키커가 한국어인 제품은 랜딩 뿌리에서 자간을 `0`으로 둔다.

새 props를 쓰든 안 쓰든 모든 제품에 적용되는 기본값: 위의 44px 터치 누르는 면, 좁은 헤더 첫 줄이 `--site-header-block-size`(64px)이고 위아래 패딩 없음 — 전역 `box-sizing: border-box` 리셋이 없는 제품은 휴대폰 헤더가 24px 낮아지고(64px에 위아래 12px 패딩이었음), 메뉴 버튼이 44px인 제품은 4px 낮아진다 — 열린 메뉴의 링크는 간격 없는 44px 줄이고 컨트롤 줄 아래 12px, 섹션·히어로의 스크롤 간격, Esc·링크로 메뉴 닫힘, 푸터 링크의 기준선 정렬과 details가 있는 줄의 첫 줄 정렬.

타입 메모: `SiteHeaderProps["locale"]`은 이제 `LocaleState | undefined`다. `SiteHeaderProps` 값에서 읽는 코드(`header.locale.locale`)는 좁혀야 한다 — 예를 들어 제품이 늘 넘긴다면 그 값을 `SiteHeaderProps & { locale: LocaleState }`로 타입한다.

## 브랜드 아이콘

모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist/<product>/`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.
Expand Down
Loading
Loading