테마
CoUI 테마 시스템 가이드
목차
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'),
)
커스텀 테마#
ThemeConfig 는 색 소스를 Tailwind config + --coui-* CSS 변수로 굽는다.
CoUI 기본 색은 ThemeConfig.defaults() — 디자이너가 정한 고정 팔레트
CouiColorPresets.source 다. 브랜드 색은 PrimitiveColors.generate(...)
로 만든
팔레트를 ThemeConfig(palette) 에 넘긴다(아래 예시).
[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),
// 예시 시드일 뿐, Cocode 민트 측정값이 아니다.
// 브랜드 채움 역할은 CoreColors.accent (= tertiary 슬롯).
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 모드#
// 라이트·다크·고대비 네 스킴의 출처 — CoUI 기본 색.
const colors = CouiSemanticColors.from(CouiColorPresets.source);
CoUIApp(
theme: const ThemeData(),
darkTheme: const ThemeData.dark(),
themeMode: CoreThemeMode.system,
// 사용자가 OS 에서 고대비를 켰을 때 쓰일 세트 — 같은 색에서 만든다.
// 주지 않으면 위 두 테마로 폴백한다 (검토하지 않은 세트를 렌더하는 것보다 낫다).
highContrastTheme: ThemeData.fromCore(
CoreThemeData(
brightness: CoreBrightness.light,
designSystem: DesignSystem.coui,
colorScheme: colors.highContrastLight,
),
),
highContrastDarkTheme: ThemeData.fromCore(
CoreThemeData(
brightness: CoreBrightness.dark,
designSystem: DesignSystem.coui,
colorScheme: colors.highContrastDark,
),
),
home: MyHomePage(),
)
브랜드 팔레트를 쓰는 앱은 CouiSemanticColors(palette) 로 바꿉니다 — 위
Web 커스텀 테마
절의 ThemeConfig 에 넘긴 것과 같은 팔레트(와 매핑)를 넘겨야
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-*정의가 없어 아무 일도 일어나지 않습니다.
부분 트리만 다른 밝기로#
어두운 편집기 안에 밝은 앱 화면을 미리 보이는 것처럼, 페이지와 다른 밝기로 한 부분만
그려야 할 때는 BrightnessScope 로 감쌉니다. 두 플랫폼이 같은 이름·같은 인자로 받습니다.
BrightnessScope(
brightness: CoreBrightness.light,
child: preview,
)
범위 안에서 바뀌는 것은 그 밝기의 색과 그림자 세기입니다 — 팔레트(고대비를 켰으면 고대비
세트, 서페이스 스타일이 굽힌 색 포함), 맨 Text·아이콘의 기본 색, 그림자의 세기, 재질의 채움
몫과 어둡게 하기. 그대로인 것은 컴포넌트 테마·타이포그래피·밀도·배율과 서페이스 스타일의
기하·재질 자체입니다. MockupDevice 의 screenBrightness
가 이
범위로 화면만 감쌉니다.
범위 안에서 연 팝오버·메뉴·툴팁·호버 카드·대화상자도 범위의 밝기로 그려집니다. 서랍은 연 자리가 아니라 자기가 올라가는 자리의 밝기로 그려지므로, 기본 자리(앱의 루트)에 올리면 범위 안에서 열어도 앱의 밝기입니다.
CoUIApp 이 앱의 테마를 두 밝기로 — 활성 테마와 같은 방식(고대비·서페이스 스타일·밀도·배율)으로
접어 — 함께 내려 주고, 범위는 그중 그 밝기의 것을 씁니다. 테마는 넘긴 자리가 아니라 자기
밝기로 셉니다 — theme 하나에 어두운 테마를 넘겨 스스로 바꾸는 앱도 있기 때문입니다. 앱이 그
밝기의 테마를 주지 않았으면 CoUI 의 그 밝기 프리셋을 같은 방식으로 접어 씁니다. 그래서 브랜드
팔레트를 쓰는 앱은 theme 과 darkTheme 를 모두 넘겨야 범위가 브랜드 색으로 그립니다(Web 은
ThemeConfig 의 팔레트에서 두 밝기를 만듭니다). CoUIApp
밖(테스트 하네스, 손으로 만든 루트)에서는
주변 테마와 밝기가 같으면 그 색을, 다르면 CoUI 의 그 밝기 프리셋을 접지 않은 채 씁니다 — 서페이스
스타일의 색 굽힘과 재질의 밝기별 몫이 빠지므로, 자기 테마를 접는 루트는 AppBrightnessThemes
로
두 밝기를 직접 내려 줍니다.
범위는 data-theme 를 단 상자 없는 래퍼(display: contents)이고,
ThemeConfig.cssVariables 에 든 범위 규칙이 그 안의 색 변수를 다시 선언합니다. 그 규칙은
:not(:root) 로 루트를 비켜 가므로 페이지의 밝기는 그대로이고, 다크 블록을 주입하지 않은
라이트 전용 앱에서도 두 밝기의 범위가 모두 동작합니다.
범위 안에서 연 패널은 오버레이 호스트로 옮겨져 래퍼 밖에 그려집니다. 그래서 패널을 여는 컴포넌트가 연 자리의 범위를 패널 둘레에 한 번 더 감쌉니다 — Flutter 의 그 패널이 연 자리의 테마를 가져가는 것과 같은 결과입니다.
도달 범위 — 여기까지만 참입니다
- 토큰을 거치지 않은 색(호출부가 적은 리터럴 색)은 범위 안에서도 그대로입니다.
-
Web 에서 실행 중에 바꾼 팔레트(
CoUIThemeController.setCustomTheme)는 루트 요소에 쓰이므로 범위에 닿지 않습니다. 범위는ThemeConfig가 만든 팔레트를 유지합니다. - Web 에서 서로 반대인 범위를 겹쳐 넣으면(밝은 범위 안의 어두운 범위) 색 변수는 가장 가까운 범위를 따르지만, 재질의 채움 몫과 어둡게 하기는 두 규칙이 동률이 되어 밝은 쪽이 이깁니다.
서페이스 스타일#
밝기와 직교하는 두 번째 테마 축입니다. 한 번 고르면 전 컴포넌트가 같이 움직이고, light/dark 어느 쪽에서도 켤 수 있어 둘은 목록이 아니라 매트릭스로 조합됩니다.
| 스타일 | 무엇을 진술하나 |
|---|---|
defaultStyle | 기본 표면 — 불투명, 선으로 경계, 평평 |
neoBrutalism | 두꺼운 테두리, 흐림 없는 대각 그림자, 굵은 글씨 |
liquidGlass | 반투명 + 배경 블러 |
claymorphism | 둥글고 부푼 표면 + 안쪽 하이라이트 림 |
neumorphism | 페이지와 같은 색 표면, 양방향 그림자만으로 경계 |
CoUIApp(
theme: ThemeData.fromCore(CoreThemePresets.light),
surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
home: MyHomePage(),
)
문서에 data-coui-style 속성을 쓰면 전환됩니다 — 트리를 다시 빌드하지 않습니다.
// 스타일별 CSS 규칙은 ThemeConfig 가 굽습니다.
ThemeConfig.defaults().surfaceStyleVariableBlocks
브랜드 색은 프리셋이 건드리지 않습니다#
프리셋은 팔레트를 대체하지 않고 굽힙니다. 그중 hue 는 절대 건드리지 않습니다 — 채도와 명도만 움직입니다. 앱이 가져온 파랑은 어떤 프리셋에서도 그 파랑으로 남고, 그 프리셋다운 파랑이 될 뿐입니다.
그래서 "강조색을 바꾸고 싶다" 는 서페이스 스타일이 아니라 팔레트의 일입니다:
final palette = PrimitiveColors.generate(
primaryHSL: (h: 220, s: 0.9, l: 0.6), // 여기가 강조색을 정합니다
// …
);
프리셋 튜닝#
프리셋이 저작한 값을 앱이 정할 수 있습니다. 모서리·그림자 캐스트·테두리 두께가 대상입니다. 유리 재질의 값(채움 · 블러 · 채도 · 빛의 방향 · 굴절의 법칙 · 서리 · 몸체 음영)은 같은
CoreSurfaceTuning 의 다른 필드이고 서페이스 스타일 가이드에 있습니다.
CoUIApp(
surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
surfaceTuning: const CoreSurfaceTuning(
radiusCap: CoreRadius.radius0, // 각진 모서리
shadowOffsetX: 6,
shadowOffsetY: 6, // 더 멀리 던지는 캐스트
strokeFloor: CoreStrokeWidth.stroke3,
stageTintChroma: 0.06, // 페이지가 강조색 hue 를 받는다
),
home: MyHomePage(),
)
ThemeConfig.from(
CouiColorPresets.source,
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 까지 갑니다.
그래서 배경 틴트는 다크 모드에서만 작동하는 것이 의도입니다. 라이트 기본 페이지 명도 L=0.991 에서는 sRGB 가 담을 수 있는 채도가 약 0.004(연속 게멋 경계 기준 최악 hue — 위 표의 0.006 은 이산 sweep 값이라 조금 높게 나옵니다)라 틴트가 사실상 보이지 않습니다. 다크 기본 페이지는 0.038 을 담으므로 틴트가 분명히 읽힙니다.
라이트 페이지 명도를 낮춰 틴트 자리를 만드는 안은 채택하지 않았습니다(#4234 측정). 페이지(surface) 한 칸만 내리면 표면 톤 계단이 무너집니다 — L 0.96 에서는 페이지가
surfaceContainerLow·surfaceDim 과 같은 색이 되어 카드·입력의 경계가 사라지고, L 0.95 에서는 페이지가
surfaceContainerLow 보다 어두워져 계단 순서가 뒤집힙니다. 두 경우 모두 destructive 버튼 글자 대비도 4.5:1 아래로 떨어집니다. 계단을 지키려면 흰색 계열 톤 전체를 함께 내려야 하고, 그 대가는 틴트가 얻는 채도 여유보다 큽니다.
레퍼런스 구현들의 뚜렷한 파스텔 배경(대략 0.04)은 채도를 더 요청해서가 아니라 덜 흰 페이지를 쓰기 때문입니다. 자기 팔레트를 소유한 앱이 그런 배경을 원하면 페이지를 덜 희게 잡을 수 있습니다 — L 0.95 면 0.027, 0.92 면 0.043 이 어떤 hue 에서도 보장됩니다. 단 그때는 위와 같은 이유로 컨테이너 톤도 함께 내려 계단을 지켜야 합니다.
maxStageTintChroma 는 그래서 대비 한계가 아니라 요청값의 sanity ceiling 입니다. 안전을 만드는 건 게멋이고,
check_contrast.dart 의 sweep 이 "페이지가 담을 수 있는 최대 틴트"를 전 브랜드 hue × 두 모드에서 감사해 그걸 증명합니다.
고대비에서는 틴트가 꺼집니다. 가독성을 요청한 사용자가 앱이 고른 색조를 요청한 것은 아닙니다. 반대로 모서리·캐스트·두께는 고대비에서도 남습니다 — 대비 레버가 아니라서 버려도 이득이 없습니다.
튜닝할 수 없는 것
접근성 계약(a11y) · 스타일 id · 재질 · 글자 굵기 하한은 노출되지 않습니다.
boundaryMechanism 은 강제 색상 모드에서 경계가 살아남는다는 선언이고, 컴포넌트가 그 선언을 근거로 대체 경계를 그립니다. 앱이 그걸 바꿀 수 있으면 선언이 값이 아니라 거짓말이 되고, 그 손상은 컴포넌트 140개에 동시에 퍼지면서 각 컴포넌트 리뷰에서는 "원래 이렇게 생겼나 보다" 로 읽힙니다.
여기 없는 필드는 원칙이 아니라 오늘 없는 것이고, 축은 늘어날 수 있습니다.
밀도#
밝기 · 서페이스 스타일과 나란히 놓이는 테마 축입니다. 간격 · 컨트롤 행 높이 · 그 행의 모서리를 한 번에 촘촘하게(compact) 또는 여유 있게(spacious) 옮깁니다. 기본값
standard 는 아무것도 바꾸지 않습니다.
| 밀도 | 간격 | 컨트롤 행 | 행의 모서리 |
|---|---|---|---|
compact |
4px 격자에서 한 칸 아래 (16 → 12, 8 → 4) | 한 칸 아래 (40 → 32), 32 에서 멈춤 | 한 칸 아래 (10 → 8), 8 에서 멈춤 |
standard | 그대로 | 그대로 | 그대로 |
spacious |
한 칸 위 (16 → 20) | 한 칸 위 (40 → 48), 64 에서 멈춤 | 한 칸 위 (10 → 12), 16 에서 멈춤 |
배수가 아니라 사다리 위의 한 칸이라 비율이 값마다 다릅니다(8 → 4 는 절반, 16 → 12 는 4분의 3). 그래서 compact 의 md 버튼은 sm 버튼과 정확히 같은 높이 · 모서리 · 패딩이 됩니다.
CoUIApp(
theme: ThemeData.fromCore(CoreThemePresets.light),
density: CoreDensity.compact,
home: MyHomePage(),
)
문서의 data-coui-density 속성이 간격 · 행 높이 · 모서리 변수를 덮습니다 — 트리를 다시 빌드하지 않습니다. 앱 루트에 적으면 서버 렌더 마크업에 바로 실리고, 실행 중에는 스코프의 정적 메서드로 바꿉니다.
CoUIWeb(
theme: ThemeData.coui,
density: CoreDensity.compact,
child: MyHomePage(),
)
// 실행 중 전환
DensityScope.setDensity(CoreDensity.spacious);
밀도 규칙은 ThemeConfig.cssVariables 에 함께 들어 있습니다. 서페이스 스타일과 함께 쓰면 두 축이 만나는 모서리는 surfaceStyleVariableBlocks
의 조합 규칙이 합성합니다(밀도 먼저, 스타일 나중).
움직이지 않는 것#
-
호출자가 적은 높이 —
buttonStyle: CoreButtonStyle(height: 40)처럼 직접 적은 높이 · 최소 · 상한은 사다리의 행이 아니라 진술이라 그대로입니다. 밀도가 옮기는 것은 기본값으로 사다리의 단을 적은 행입니다 — 크기(size: .md)가 고른 행과, 크기 축 없이md단에 앉는 필드(칩 입력 · 전화번호 입력 · 항목 선택의 검색 행)가 그렇습니다. 간격은 다릅니다 — 직접 적은 간격도 스케일 위에 있으면 움직입니다. - 자기 행을 가진 행 — 칩 · 탭 · 리스트 행 · 페이지 버튼, 필터의 세그먼트(막대는 모든 밀도에서 40 입니다), 탭 패널의 바, 트리의 행.
- 간격이 아닌 길이 — 아이콘 · 아바타 · 달력 칸 같은 크기, 배지 · 오버레이 · 팝오버처럼 다른 요소에 맞대어 놓는 위치 오프셋, 스크롤바 thumb · 토글 트랙 · 토글 렌즈 같은 컨트롤 안의 기하, 타이포.
- 4 이하의 간격 — 아이콘과 라벨처럼 붙어 있어야 하는 사이라 움직이지 않습니다.
- Web 에서 CSS 변수를 거치지 않는 스케일 밖 간격 — 리터럴로 나가 밀도가 닿지 않습니다. Flutter 는 이것도 옮기므로 이 자리에서는 두 플랫폼이 갈립니다. 정상 경로의 간격은 토큰 가드가 스케일 위로 묶습니다.
캐러셀의 슬라이드 사이 간격 · 대화상자가 화면 가장자리에 남기는 여백(CoreDialogStyle.routePadding) · 팝오버가 화면 가장자리에서 띄우는 여백은 간격이라 움직입니다. Web 의 위치 엔진에 숫자로 넘기는 여백은 패널을 여는 순간의 밀도로 옮겨 넘기므로, 연 채로 밀도를 바꾸면 다음에 열 때 따라갑니다. Flutter 의 토스트 레이어(토스트 사이 간격 · 화면 가장자리 여백)도 움직입니다 — Web 에는 토스트 레이어가 없습니다.