테마#
Figma Foundation · 00. Overview · 값은
coui_core가 정본입니다. 정본 파일은 Figma 라이브러리. 반경·모션 스케일은 형태 · 애니메이션.
CoUI는 Flutter와 Web 양쪽에서 일관된 테마 시스템을 제공합니다.
Flutter 테마#
CoreComponentTheme#
프로젝트 전체에 컴포넌트 테마를 주려면 CoreComponentTheme 을 만들어
ThemeData.fromCore 에 넘기고, 그 ThemeData 를 CoUIApp
에 준다.
CoUIApp(
theme: ThemeData.fromCore(
CoreThemePresets.light,
coreComponentTheme: const CoreComponentTheme(
button: CoreButtonTheme(
style: CoreButtonStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
),
),
badge: CoreBadgeTheme(
style: CoreBadgeStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space8,
vertical: CoreSpace.space2,
),
),
),
),
),
home: MyHomePage(),
)
CoreComponentTheme 은 컴포넌트마다 슬롯 하나(button / badge / …)를 갖고,
각 CoreXxxTheme 은 style 과 variantStyles 두 슬롯만
갖는다. chrome 을
Theme 에 평면 필드로 얹지 않는 이유는 그 값이 위젯의 xxxStyle 슬롯과 두 경로로
들어와 우선순위가 모호해지기 때문이다.
variant 별 오버라이드#
variantStyles 로 특정 variant 만 다르게 줄 수 있다.
const CoreCardTheme(
style: CoreCardStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
variantStyles: {
CoreCardVariant.filled: CoreCardStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
),
},
)
우선순위#
chrome 값은 항상 한 방향으로 흐른다 — 아래로 갈수록 이긴다.
CoreXxxStyle.defaultsByVariant[variant] // 디자인 시스템 default
→ CoreXxxStyle.defaultX
→ CoreXxxTheme.style // 프로젝트 공통
→ CoreXxxTheme.variantStyles[variant] // 프로젝트 · variant 별
→ widget.xxxStyle // 인스턴스별
이 머지는 컴포넌트의 resolveXxx 안에서 한 번에 일어나고, 위젯은 그 결과만 읽는다.
Web 테마#
앱 루트#
Web 의 앱 루트는 CoUIWeb 이고, Flutter CoUIApp 과 같은 CoreComponentTheme
슬롯을 받습니다:
CoUIWeb(
theme: ThemeData.fromCore(
CoreThemePresets.light,
coreComponentTheme: const CoreComponentTheme(
button: CoreButtonTheme(
style: CoreButtonStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
),
),
),
),
child: MyApp(),
)
CoUI 테마#
theme: 은 컴포넌트 테마와 타이포그래피를 정합니다. 색 팔레트를 라이트/다크로
뒤집는 것은 theme: 이 아니라 data-theme 속성입니다
— ThemeConfig 가
[data-theme="dark"] 에 --coui-* 오버라이드를 정의해 두기 때문입니다:
<html data-theme="light">
<!-- 또는 -->
<html data-theme="dark">
Named Properties 패턴#
Web 컴포넌트는 Flutter와 동일한 named properties API를 씁니다 — 시맨틱 값은
위젯 파라미터로, chrome/dimensional 미세 조정은 xxxStyle 슬롯으로 분리됩니다
(위 button.md의 "시맨틱 vs 스타일" 참조).
Button(
variant: CoreButtonVariant.primary,
size: CoreComponentSize.lg,
onPressed: () {},
child: Text('Large Primary Button'),
)
커스텀 테마#
CoUI Web의 색은 PrimitiveColors.generate(...)로 만든 팔레트에서 파생되고,
ThemeConfig가 그 팔레트를 Tailwind config + --coui-*
CSS 변수로 굽는다.
[data-theme="custom"] { --p: ...; } 처럼 DaisyUI 변수(--p/--s/--a/
--n/--b1)를 직접 정의하는 방식은 작동하지 않는다
— 이 변수들은
primitive_css_generator.dart의 generateDaisyUiAliases()가 반대 방향으로
(예: --p: var(--coui-primary)) 이미 채워 넣는 deprecated 하위호환 별칭
이고, 실제 팔레트를 정의하는 진입점이 아니다.
import 'package:coui_web/coui_web.dart';
final config = ThemeConfig(
PrimitiveColors.generate(
primaryHSL: (h: 262.0, s: 0.80, l: 0.50),
secondaryHSL: (h: 314.0, s: 0.72, l: 0.45),
tertiaryHSL: (h: 174.0, s: 0.72, l: 0.56),
successHSL: (h: 140.0, s: 0.70, l: 0.42),
infoHSL: (h: 200.0, s: 0.75, l: 0.50),
warningHSL: (h: 45.0, s: 0.85, l: 0.50),
errorHSL: (h: 0.0, s: 0.75, l: 0.50),
neutralHue: 218.0,
),
);
// <head> 안에서:
// <script>{config.tailwindConfig}</script>
// <style>{config.cssVariables}</style>
디자인 토큰#
CoUI는 coui_core의 디자인 토큰으로 시각적 일관성을 보장합니다. 축 전체 맵은 디자인 토큰 을 먼저 보세요.
CoreRadius#
Border radius 는 숫자 primitive 만 쓴다 (radius0 ~ radius64, radius9999). 소비자 가이드는
형태 입니다.
CoreRadius.radius4 // 4px
CoreRadius.radius8 // 8px
CoreRadius.radius16 // 16px
CoreRadius.radius9999 // pill
field / selector / box 같은 글로벌 시맨틱 radius 토큰은 의도적으로 없다
—
컴포넌트가 자기 CoreXxxStyle.defaultBorderRadius 에서 숫자 primitive 를 직접 고른다.
그래서 "input 은 어느 radius 인가" 는 그 컴포넌트의 Style 을 보면 답이 나오고,
전역 별칭을 바꿔 의도치 않은 컴포넌트까지 움직이는 일이 없다.
Flutter 는 BorderRadius.circular(CoreRadius.radiusN), Web 은
rounded-${CoreRadius.scale.radiusN} Tailwind 키로 같은 값을 참조한다.
CoreDuration#
애니메이션 타이밍을 통일합니다:
// 시맨틱 alias tier — 컴포넌트가 참조하는 것
CoreDuration.instant // 100ms — 체크 표시, 토글
CoreDuration.fast // 150ms — 툴팁 등장, 버튼 hover, 입력 포커스
CoreDuration.normal // 200ms — 드롭다운, 카드 hover, 아코디언
CoreDuration.moderate // 300ms — 모달 진입, 패널 슬라이드
CoreDuration.slow // 500ms — 페이지 레벨 전환
// raw 스케일 — 위 alias 가 가리키는 값 (직접 참조 대신 alias 사용)
CoreDuration.ms0 … CoreDuration.ms500 // 전환 tier (50ms 단위)
CoreDuration.ms1000 … CoreDuration.ms5000 // 표시 tier (alias 없음)
alias 를 거치는 이유는 이름이 값의 근거를 말해주기 때문이기도 하지만, 무엇보다 전역 모션 오버라이드(reduced-motion)가 걸 수 있는 단일 어휘가 되기 때문입니다. 컴포넌트가 raw
ms150 을 직접 쓰면 그 오버라이드가 그 컴포넌트만 비껴갑니다.
// CoreDuration 토큰을 Duration으로 경유
AnimatedContainer(
duration: Duration(milliseconds: CoreDuration.normal),
curve: Curves.easeInOut,
child: content,
)
// Tailwind duration 클래스로 접근
// transition-colors duration-150
// transition-shadow duration-200
Light/Dark 모드#
CoUIApp(
theme: const ThemeData(),
darkTheme: const ThemeData.dark(),
themeMode: CoreThemeMode.system,
// 사용자가 OS 에서 고대비를 켰을 때 쓰일 세트 — 같은 팔레트에서 만든다.
// 주지 않으면 위 두 테마로 폴백한다 (검토하지 않은 세트를 렌더하는 것보다 낫다).
highContrastTheme: ThemeData.fromCore(
CoreThemeData(
brightness: CoreBrightness.light,
designSystem: DesignSystem.coui,
colorScheme: CoreSemanticMapping.highContrastLight(palette),
),
),
highContrastDarkTheme: ThemeData.fromCore(
CoreThemeData(
brightness: CoreBrightness.dark,
designSystem: DesignSystem.coui,
colorScheme: CoreSemanticMapping.highContrastDark(palette),
),
),
home: MyHomePage(),
)
palette는 CouiColorPresets.defaultPalette(기본) 또는 앱이 PrimitiveColors.generate(...)로
만든 커스텀 팔레트입니다 — 위 Web 커스텀 테마
절의 PrimitiveColors.generate
와 같은 값을 공유해야 Flutter/Web 고대비 세트가 같은 팔레트에서 파생됩니다.
Web은 Flutter처럼 테마 객체를 갈아끼우지 않습니다. 색을 뒤집는 것은 위
CoUI 테마 절의 <html data-theme>
속성이고, ThemeConfig가
[data-theme="dark"]에 --coui-* 오버라이드를 정의해 둡니다. 그래서
프로그래밍 방식 전환은 그 속성을 쓰는 것입니다:
CoUIThemeController.setTheme('dark');
사용자가 직접 고르게 하려면 ThemeController를 씁니다 — 앱을 감싸는 래퍼가
아니라 선택 UI입니다:
ThemeController(
themes: const [
CoreThemeOption(
name: 'light',
primaryColor: CoreColor.token(CoreColors.primary),
),
CoreThemeOption(
name: 'dark',
primaryColor: CoreColor.token(CoreColors.primary),
),
],
selectedTheme: 'light',
onThemeChanged: CoUIThemeController.setTheme,
)
CSS 변수가 정의된 테마만 동작합니다 — 현재
ThemeConfig가 값을 정의하는 것은light와dark둘뿐입니다.data-theme에 다른 이름을 넣어도 대응하는--coui-*정의가 없어 아무 일도 일어나지 않습니다.
서페이스 스타일#
밝기와 직교하는 두 번째 테마 축입니다. 한 번 고르면 전 컴포넌트가 같이 움직이고, light/dark 어느 쪽에서도 켤 수 있어 둘은 목록이 아니라 매트릭스로 조합됩니다.
| 스타일 | 무엇을 진술하나 |
|---|---|
defaultStyle | 기본 표면 — 불투명, 선으로 경계, 평평 |
neoBrutalism | 두꺼운 테두리, 흐림 없는 대각 그림자, 굵은 글씨 |
liquidGlass | 반투명 + 배경 블러 |
claymorphism | 둥글고 부푼 표면 + 안쪽 하이라이트 림 |
neumorphism | 페이지와 같은 색 표면, 양방향 그림자만으로 경계 |
CoUIApp(
theme: ThemeData.fromCore(CoreThemePresets.light),
surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
home: MyHomePage(),
)
문서에 data-coui-style 속성을 쓰면 전환됩니다 — 트리를 다시 빌드하지 않습니다.
// 스타일별 CSS 규칙은 ThemeConfig 가 굽습니다.
ThemeConfig(CouiColorPresets.defaultPalette).surfaceStyleVariableBlocks
브랜드 색은 프리셋이 건드리지 않습니다#
프리셋은 팔레트를 대체하지 않고 굽힙니다. 그중 hue 는 절대 건드리지 않습니다 — 채도와 명도만 움직입니다. 앱이 가져온 파랑은 어떤 프리셋에서도 그 파랑으로 남고, 그 프리셋다운 파랑이 될 뿐입니다.
그래서 "강조색을 바꾸고 싶다" 는 서페이스 스타일이 아니라 팔레트의 일입니다:
final palette = PrimitiveColors.generate(
primaryHSL: (h: 220, s: 0.9, l: 0.6), // 여기가 강조색을 정합니다
// …
);
프리셋 튜닝#
프리셋이 저작한 값을 앱이 정할 수 있습니다. 모서리·그림자 캐스트·테두리 두께가 대상입니다.
CoUIApp(
surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
surfaceTuning: const CoreSurfaceTuning(
radiusCap: CoreRadius.radius0, // 각진 모서리
shadowOffsetX: 6,
shadowOffsetY: 6, // 더 멀리 던지는 캐스트
strokeFloor: CoreStrokeWidth.stroke3,
stageTintChroma: 0.06, // 페이지가 강조색 hue 를 받는다
),
home: MyHomePage(),
)
ThemeConfig(
CouiColorPresets.defaultPalette,
surfaceTunings: const {
CoreSurfaceStyleId.neoBrutalism: CoreSurfaceTuning(
radiusCap: CoreRadius.radius0,
shadowOffsetX: 6,
shadowOffsetY: 6,
strokeFloor: CoreStrokeWidth.stroke3,
stageTintChroma: 0.06,
),
},
)
이름 짓지 않은 축은 프리셋의 답을 그대로 씁니다 — radiusCap 만 적으면 캐스트와 두께는 프리셋 값입니다.
빌드 시점 결정입니다. 스타일 전환은 Web 에서 속성 하나로 되지만 튜닝은 연속값이라, 속성으로 고르려면 값마다 선택자가 하나씩 필요합니다.
눌림 피드백이 캐스트에서 파생됩니다 — shadowOffset 을 키우면 눌렀을 때 물러나는 거리도 같이 커집니다. 두 값을 따로 맞출 필요가 없습니다.
범위
| 축 | 범위 |
|---|---|
radiusFloor / radiusCap |
≥ 0, floor ≤ cap. 사다리 밖 값은 변환이 알아서 스냅합니다 |
shadowOffsetX / shadowOffsetY |
≥ 0
.
상한 없음
— 큰 캐스트가 이웃을 침범하는 건 앱이 소유한 미감이지 계약 위반이 아닙니다. 음수만 막습니다(아래-오른쪽이 이 스타일의 진술입니다)
|
strokeFloor |
CoreStrokeWidth.stroke2 이상, 그 프리셋의 포커스 링 폭 미만 |
stageTintChroma |
0 ~ CoreSurfaceTuning.maxStageTintChroma. 아래 절 참조 — 이 상한은 대비 한계가 아닙니다 |
범위를 벗어난 값은 대부분 컴파일 에러입니다(const 호출부 기준). strokeFloor 상한만 프리셋마다 다른 값에서 파생되므로 디버그 첫 fold 에서 걸립니다.
테두리가 포커스 링과 같아지면 링이 테두리의 일부로 읽혀 키보드 사용자가 자기 위치를 잃습니다. 그래서 상한이 링 폭에서 파생되고, 링이 바뀌면 상한도 따라갑니다.
배경 틴트 — 실제로 결정하는 건 페이지의 밝기입니다
stageTintChroma 는 페이지(surface)가 강조색 hue 를 얼마나 받는지 정합니다. 페이지만
물듭니다 — 카드·입력·팝오버를 구분하는 컨테이너 톤은 그대로입니다. 같이 물들이면 그 층이 사라지고 화면이 한 덩어리가 됩니다.
페이지의 명도(lightness)는 움직이지 않습니다. 대비는 상대 휘도의 함수라, 명도를 열면 그 위의 모든 전경 쌍이 함께 움직입니다.
그리고 여기서 나오는 결과가 직관과 다릅니다 — sRGB 는 밝은 색일수록 담을 수 있는 채도가 적습니다. 명도를 고정한 채 요청한 채도가 게멋 밖이면, CoUI 는 clamp 하지 않고 게멋 안으로 줄여서 명도를 지킵니다. 그래서 실제로 실리는 틴트는 요청값이 아니라 페이지가 담을 수 있는 만큼입니다:
| 페이지 명도 | 어떤 브랜드 hue 에서도 보장되는 채도 |
|---|---|
| 0.991 (CoUI 라이트 기본) | 0.006 |
| 0.95 | 0.027 |
| 0.92 | 0.043 |
| 0.213 (CoUI 다크 기본) | 0.038 |
브랜드 hue 는 앱이 고르므로 위 값은 최악 hue 기준입니다. hue 에 따라 더 담기기도 합니다 — 같은 라이트 페이지도 파랑 계열에서는 0.016 까지 갑니다.
숫자는 작지만 화면에서는 보입니다 — 페이지 전면 단색 영역에서는 작은 채도도 지각되기 때문입니다. 기본 라이트 팔레트에 상한 틴트를 걸고 실제로 렌더하면 페이지가
#fcfcfd 에서 #f6fdff
로 가고, 옅은 하늘색 wash 로 분명히 읽힙니다(측정 채도 0.008).
기대치만 맞추면 됩니다 — 레퍼런스 구현들의 뚜렷한 파스텔 배경과는 다릅니다. 그쪽은 대략 0.04 로 5배 진하고, 그 차이는 채도를 더 요청해서가 아니라 페이지가 더 어둡기 때문입니다. 다크 모드는 0.038 로 그 깊이에 거의 닿습니다.
레퍼런스 구현들의 뚜렷한 파스텔 배경은 채도를 더 요청해서가 아니라 덜 흰 페이지를 쓰기 때문입니다. 그런 배경이 필요하면 팔레트에서 페이지를 덜 희게 잡으세요 — L 0.95 면 0.027, 0.92 면 0.043 이 어떤 hue 에서도 보장됩니다.
maxStageTintChroma 는 그래서 대비 한계가 아니라 요청값의 sanity ceiling 입니다. 안전을 만드는 건 게멋이고,
check_contrast.dart 의 sweep 이 "페이지가 담을 수 있는 최대 틴트"를 전 브랜드 hue × 두 모드에서 감사해 그걸 증명합니다.
고대비에서는 틴트가 꺼집니다. 가독성을 요청한 사용자가 앱이 고른 색조를 요청한 것은 아닙니다. 반대로 모서리·캐스트·두께는 고대비에서도 남습니다 — 대비 레버가 아니라서 버려도 이득이 없습니다.
튜닝할 수 없는 것
접근성 계약(a11y) · 스타일 id · 재질 · 글자 굵기 하한은 노출되지 않습니다.
boundaryMechanism 은 강제 색상 모드에서 경계가 살아남는다는 선언이고, 컴포넌트가 그 선언을 근거로 대체 경계를 그립니다. 앱이 그걸 바꿀 수 있으면 선언이 값이 아니라 거짓말이 되고, 그 손상은 컴포넌트 140개에 동시에 퍼지면서 각 컴포넌트 리뷰에서는 "원래 이렇게 생겼나 보다" 로 읽힙니다.
여기 없는 필드는 원칙이 아니라 오늘 없는 것이고, 축은 늘어날 수 있습니다.