테마 | CoUI
본문으로 건너뛰기
LogoCoUI

테마

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.950.027
0.920.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 에는 토스트 레이어가 없습니다.