120 lines
13 KiB
Markdown
120 lines
13 KiB
Markdown
# 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/동작은 바꾸지 않았다.
|
||
|