Files

13 KiB
Raw Permalink Blame History

Runtime Theme Contract v1

브랜드 컬러 8단계의 확정값·Legacy 대응 보류 및 공개 Key 보존은 브랜드 컬러 단계를 따른다. 미확정 표시는 새 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. 디자인팀 Preview/편집: 사용법. 전체 입력/API 계약: Runtime 계약표.

React 외 실행 형식으로 변환할 때도 이 문서의 Token·CSS Custom Properties·Runtime 적용 계약을 유지한다. Target Runtime, standalone asset, file:// 검증 및 AI/도구 독립성 기준은 Runtime Portability Policy를 따른다.

소비 CSS는 tailwindcss → @hm-design/ui/tailwind.css → @hm-design/ui/theme.css 순서로 앱 entry에서 로드한다. Dashboard 상태 표시가 있으면 그 뒤에 @hm-design/ui/dashboard-status.css와 소비 Badge mapping을 연결한다(상태 예제). Status CSS는 변수만 정의하며 94개 Runtime Theme 계약에 새 상태 Token을 추가하지 않는다. Tailwind 검색 범위와 Noto Sans KR·Asset 경로는 실행 환경별 소비 가이드를 따른다.

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 94개 계약에도 포함하지 않는다.

기존 25개 allowlist의 JSON 이름과 validation을 유지하고 Active / on Active / Inverse Text / Subtle Surface / Accent / on Accent를 추가했다. 같은 CSS property로 향하는 중복 공개 key는 없다. Component 수백 개의 Token을 요구하는 대신 Theme 팔레트는 선택적 확장이다. 핵심 역할만 지정해도 사용할 수 있다. 팔레트 이름은 색상 계열을 유지하며, 역할 Token은 각각 독립적으로 override할 수 있다.

전체 입력 목록과 영향은 Runtime 계약표, 구조와 적용 절차는 이 문서와 Theme delivery를 따른다. 과거 감사 CSV·작업 보고서는 테스트 ZIP에 포함하지 않는다. 새 공개 Token 추가는 패키지/계약 변경이다. schemaVersion: 1 입력 구조를 유지하며 별도 발행/versioning 시스템은 만들지 않았다.

기본 적용

앱 초기 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 적용

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은 입력할 수 없다.
  • Green 배경·Primary 면·Primary border·Brown 배경·Orange 배경의 5개 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 값을 상속하는 문제를 피한다.

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 문서를 따른다.

초기 렌더

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/동작은 바꾸지 않았다.