Files
HMDesign/docs/guidelines/runtime-theme.md
T

120 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Runtime Theme Contract v1
브랜드 컬러 8단계의 확정값·Legacy 대응 보류 및 공개 Key 보존은 [브랜드 컬러 단계](brand-color-steps.md)를 따른다. 미확정 표시는 새 Runtime Token이나 색상 승인이 아니다.
2026-10-06 · 분류: HM_THEME / Theme infrastructure. Component props, DOM, Radix 상태, Portal, 키보드, ARIA, breakpoint는 변경하지 않았다.
## 현재 파일과 적용 흐름
`themes/default.json` → 생성 `packages/ui/src/theme/primitives.css` / `default.css` HM fallback / Semantic → `<component>.css` Component Mapping → `[data-slot]` CSS → UI. 운영 External JSON은 같은 Contract로 Runtime 적용한다.
`packages/ui/src/theme/contract.ts`는 외부 JSON key, CSS property, 타입과 생성 Default 데이터를 연결하는 공개 계약이다. `runtime.ts`가 검증 후 DOM scope에 inline CSS 변수를 넣는다. 서비스는 `@hm-design/ui/theme`를 사용한다. `src/theme/preview.ts`는 같은 함수의 호환 adapter다. Production URL Loader는 `loader.ts`의 fetch/resolve/load API이며 회사 CDN 주소는 없다. `.storybook/preview.tsx`는 JSON Default + partial을 Global로 적용하고 Designer Playground가 JSON URL/파일 Preview를 제공한다. React Production Provider/Publish API는 없다.
운영/분리 배포: [Theme delivery](theme-delivery.md). 디자인팀 Preview/편집: [사용법](designer-theme-guide.md). 전체 입력/API 계약: [Runtime 계약표](runtime-theme-contract.md).
React 외 실행 형식으로 변환할 때도 이 문서의 Token·CSS Custom Properties·Runtime 적용 계약을 유지한다. Target Runtime, standalone asset, `file://` 검증 및 AI/도구 독립성 기준은 [Runtime Portability Policy](runtime-portability.md)를 따른다.
소비 CSS는 `tailwindcss` → `@hm-design/ui/tailwind.css` → `@hm-design/ui/theme.css` 순서로 앱 entry에서 로드한다. Dashboard 상태 표시가 있으면 그 뒤에 `@hm-design/ui/dashboard-status.css`와 소비 Badge mapping을 연결한다([상태 예제](../patterns/dashboard-layout.md#dashboard-업무-상태-공통-계약)). Status CSS는 변수만 정의하며 84개 Runtime Theme 계약에 새 상태 Token을 추가하지 않는다. [Tailwind 검색 범위](runtime-portability.md#21-tailwind-v4-소비자-검색-범위)와 [Noto Sans KR·Asset 경로](runtime-portability.md#41-noto-sans-kr-경로-실행-환경별-확인)는 실행 환경별 소비 가이드를 따른다.
Default는 특별한 컴포넌트 내부 상수가 아니라 이 계약을 만족하는 기본 제공 Theme Set이다. 주입이 없으면 패키지 CSS의 Default preset을 사용한다. `defaultRuntimeTheme`는 동일한 값을 가진 JSON 형태 preset이다. Story의 CSS fallback / Default preset 주입 비교와 계약 값 검사로 두 표현의 일치를 검증한다.
## 공개 경계
- **공개 Runtime 계약**: Semantic 색상 역할, 기존 그라데이션 stop 세트, 선택적 Theme 팔레트, 기존 radius 2개. 모든 Token은 선택적이다.
- **내부 Derived**: `--hm-sem-surface-highlight`(Accent 45%), gradient 조합, shadow/overlay 조합, shadcn adapter(`--primary`, `--ring` 등). 외부 JSON은 이 변수를 직접 쓰지 않는다.
- **내부 Component Mapping**: `--hm-checkbox-*`, `--hm-calendar-*`, `--hm-pagination-*` 등. 기존 이름을 유지한다. Runtime JSON으로 받지 않는다.
- Typography, height, spacing, gradient geometry, shadow geometry, responsive layout은 코드 계약이다. 이번 컬러 구조 변경으로 Runtime 범위를 확대하지 않았다.
- Typography 현황(2026-10-08): 기존 크기와 ui_viz 승인 변경값을 유지한다. `packages/ui/src/theme/*.css`의 font-size는 px이고 shadcn 유래 Tailwind class(`text-sm` 등)는 rem 기반이라 단위가 혼재한다. 소비자가 Tailwind class나 `className`으로 크기를 덮어쓰면 Theme 값보다 우선할 수 있다. Typography Role(heading/body/label 등)은 아직 공통 Token으로 도입하지 않았고 Runtime JSON 84개 계약에도 포함하지 않는다.
기존 25개 allowlist의 JSON 이름과 validation을 유지하고 Active / on Active / Inverse Text / Subtle Surface / Accent / on Accent를 추가했다. 같은 CSS property로 향하는 중복 공개 key는 없다. Component 수백 개의 Token을 요구하는 대신 Theme 팔레트는 선택적 확장이다. 핵심 역할만 지정해도 사용할 수 있다. 팔레트 이름은 색상 계열을 유지하며, 역할 Token은 각각 독립적으로 override할 수 있다.
전체 입력 목록과 영향은 [Runtime 계약표](runtime-theme-contract.md), 구조와 적용 절차는 이 문서와 [Theme delivery](theme-delivery.md)를 따른다. 과거 감사 CSV·작업 보고서는 테스트 ZIP에 포함하지 않는다. 새 공개 Token 추가는 패키지/계약 변경이다. `schemaVersion: 1` 입력 구조를 유지하며 별도 발행/versioning 시스템은 만들지 않았다.
## 기본 적용
앱 초기 CSS에서 순서를 유지한다.
```css
@import "tailwindcss";
@import "@hm-design/ui/tailwind.css";
@import "@hm-design/ui/theme.css";
```
Theme 주입을 하지 않으면 HM Default가 자동 적용된다. `tailwind.css`의 HM과 중복되던 upstream root/dark 색상 preset은 제거했고 chart/sidebar fallback만 base layer에 남겼다. `theme.css`는 Default를 먼저 import하고 기존 Component/Pattern import 순서를 유지한다. Filter Bar state padding 순서도 바꾸지 않았다. Runtime inline 값은 이후 기본 CSS import보다 우선한다. `!important`로 별도 override한 소비 CSS는 이 우선순위 계약 밖이다.
## External Theme 적용
```ts
import { applyRuntimeTheme, resetRuntimeTheme } from "@hm-design/ui/theme"
const accepted = applyRuntimeTheme(document.documentElement, {
schemaVersion: 1,
tokens: {
"color.action.primary": "#6837a8",
"color.state.active": "#007f73",
"color.stroke.input.focus": "#c75b00",
"color.accent": "#ffd36d",
"color.action.destructive": "#d41466",
},
})
// 선택적 교체 및 해제. 실제 Theme 값은 서비스의 검증/승인 과정을 거친다.
resetRuntimeTheme(document.documentElement)
```
기존 Preview 함수는 입력이 `unknown`이고 반환은 `boolean`이다. 새 공개 함수도 같은 형태다. 외부 Primary는 Badge default, Pagination current, Button outline, GNB/LNB current ink를 바꾼다. Brand Button/GNB 면은 기존 stop 계약, Solid Button 면은 팔레트 계약을 사용하므로 Primary 한 개로 gradient나 Solid 면을 임의 재설계하지 않는다. 기존 stop 세트 전체를 제공하면 해당 gradient도 바뀐다.
## Partial / Empty / Invalid / Switching
- `{ schemaVersion: 1, tokens: {} }`: 유효한 빈 Theme이며 Default를 사용한다.
- 부분 Token만 제공: 같은 scope의 미지정 값은 HM Default를 사용한다. Theme A에서 B로 교체할 때 A의 키가 남지 않는다.
- `null`, 단순 `{}`, 잘못된 schemaVersion, 알 수 없는 key, Component Token key, 잘못된 값: false를 반환하고 이 helper의 이전 override를 해제한다. 정상적인 host에서는 Default로 돌아간다. host가 원래 소유한 inline override는 복원한다.
- 불투명 6자리 HEX만 허용한다. radius는 2–12px 정수만 허용한다. `var()`, `url()`, CSS 함수, `!important`, 임의 CSS rule은 입력할 수 없다.
- 기존 3개의 gradient stop 그룹은 각각 전체 또는 미지정만 허용한다. 한 stop만 보내면 문서 전체를 거부한다.
- 입력 전체를 검증한 뒤 DOM에 쓴다. 하나의 잘못된 값 때문에 CSS가 오염되는 대신 마지막 override를 해제한다. 이 atomic fallback은 기존 정책을 유지했다.
- cleanup은 helper가 덮기 전 inline 값과 priority 및 `data-hm-theme` 속성을 복원한다. 같은 scope는 한 owner가 적용/해제를 관리한다. 서로 다른 helper 호출자가 같은 scope를 동시에 소유하는 다중 owner 시스템은 제공하지 않는다.
## Global / Scoped Theme
`document.documentElement` 주입은 Global이다. container를 전달하면 helper가 `data-hm-theme="runtime"`을 설정한다. CSS의 `:root, [data-hm-theme]` 규칙이 그 scope에서 Semantic/Derived/Component Mapping을 다시 계산한다. 단순히 후손 변수만 바꾸고 root에서 이미 계산된 Component 값을 상속하는 문제를 피한다.
```ts
const panel = document.querySelector<HTMLElement>("#service-panel")!
applyRuntimeTheme(panel, {
schemaVersion: 1,
tokens: { "color.state.active": "#345678" },
})
```
명시적 scope는 **독립 HM preset + 자신의 partial override**다. Global A 아래 Scoped B의 미지정 값은 A가 아니라 HM Default다. 이 정책은 밝은/어두운 모드나 Brand selector를 새로 만들지 않고 서로 독립된 scope를 검증하기 위한 것이다. 소비 CSS만으로 적용하려면 `[data-hm-theme="service-a"] { --hm-sem-state-active: ...; }`를 기본 CSS 뒤에 선언한다. 기존 host inline 값이 있는 경우에는 그 값이 우선한다.
**Portal 경계**: CSS inheritance는 DOM을 따른다. Select, Date Picker, Sheet 등 body Portal은 wrapper scope의 자손이 아니다. Global Theme에서는 그대로 같은 값을 받는다. 현재 HM SelectContent / PopoverContent / DatePicker는 Portal container API를 공개하지 않는다. 따라서 wrapper만으로 popup을 scoped Theme로 바꿀 수 없으며, popup은 Global Theme를 따른다. raw Radix Portal을 직접 조합하는 사용처에서 이미 container를 설정할 수 있다면 해당 Theme scope를 쓰거나 포털 호스트에 같은 계약을 적용한다. 기존 HM popup에 scope 전달을 추가하는 일은 별도 API/구조 작업이다.
## 의미별 독립성 및 기존 표현
- Active: Checkbox checked, Radio selected, Switch on, Slider range/thumb border, Progress fill, Calendar single / range edge, Date Picker의 Calendar selected.
- Focus: `color.stroke.input.focus` → `--hm-sem-stroke-input-focus` → `--ring`. Checkbox/Calendar/Input/Select 등의 Focus와 Active는 독립적이다. Default에서는 둘 다 Light 2지만 Verification에서는 다른 값이다.
- Primary: Badge default, Pagination current, Button outline, GNB/LNB current/strong ink. Active와 alias하지 않는다.
- Accent 45%: Select/Menu Item/Calendar hover·range middle/Pagination hover의 Soft Highlight. 내부 `--hm-sem-surface-highlight`를 사용한다. Table row hover·selected만 동일 Accent를 20%로 계산한다. Accent 100% + On Accent: Toggle/Toggle Group의 Persistent Selected Surface, Menubar 상위 Highlighted/Open, Navigation Menu Trigger/Open. Navigation Current는 Primary + contextual surface로 Control Active와 구분한다.
- On Accent: Primary와 sibling Semantic이다. `color.on.accent`와 CSS-only package fallback은 독립 기본값을 가지며 Primary를 override해도 On Accent는 함께 바뀌지 않는다.
- Destructive: Input 오류, Badge/Button/Menu 오류 표현. Primary/Active/Focus와 독립적이다.
- Slider Hover/Drag halo는 Primary 20%, Keyboard Focus halo는 Focus Semantic 20%다. Focus와 Hover/Drag가 겹치면 Focus 색이 우선한다. GNB/LNB는 contextual navigation focus 예외(초록 면의 inverse outline, Current/Open 면의 contextual outline)를 유지한다. 팔레트 기반 Table header/Tab/navigation 장식은 Control Active 의미가 아니다. Data Table Sort Indicator는 별도 Green Light 3 palette 기반이며 [Pattern 문서](../patterns/data-table.md)를 따른다.
## 초기 렌더
Default CSS는 첫 paint 전에 로드한다. Story scope는 `useLayoutEffect`로 적용해 React paint 전에 값을 준비한다. 서비스 Global 적용은 앱 bootstrap에서 React render 전에 호출할 수 있다. 네트워크 Theme를 나중에 받으면 Default → External 변화가 생길 수 있으며 bootstrap/캐시 정책의 책임이다. 네트워크 flash 측정이나 hydration 시스템은 이번에 만들지 않았다.
## 검증 Story
`src/theme/runtime-theme.stories.tsx`는 숨김 `verification` Story다. 기존 컴포넌트 API로 Button/Input/Checkbox/Radio/Switch/Select/Slider/Progress/Calendar/DatePicker/Pagination/Badge/Table과 Command를 렌더링하며 Navigation Preview는 GNB / LNB 중 하나만 선택한다. Dashboard Theme 예제의 콘텐츠에는 외부 GNB/LNB를 넣지 않는다.
- `/iframe.html?id=theme-runtime-contract--comparison&viewMode=story`: HM Default / External 비교.
- `/iframe.html?id=theme-runtime-contract--global-external&viewMode=story`: Global External 및 실제 Select/DatePicker Portal 검증.
- `/iframe.html?id=theme-runtime-contract--verification&viewMode=story`: 자동 DOM/계약/독립성/cleanup 검사 결과.
- `/iframe.html?id=theme-runtime-contract--dashboard&viewMode=story`: 기존 Dashboard composition에 Scoped Theme 적용.
`verification-baseline.json`은 Runtime 전환 전 CSS root 변수 그래프의 **Story 전용 고정 snapshot**이다. Production import가 없다. 새 Default와 같은 컴포넌트를 렌더링해 색상·geometry를 비교한다. 기준값을 새 CSS에서 재생성하지 않는다.
자동 play와 같은 검사를 Run verification 버튼으로 다시 실행할 수 있다. Verification 화면의 transition-duration만 0으로 고정해 중간 애니메이션 프레임이 최종값 검사에 섞이지 않도록 했다. Production transition/동작은 바꾸지 않았다.