05. 디자인 토큰과 테마 전환 — `Animate`와 `Modifier.Overridden`
대상 독자: 다크/라이트 테마와 재사용 가능한 스타일 아키텍처를 만들려는 개발자 다루는 개념: 디자인 토큰,
q.Context,state:Apply(factory),q.Operator.Indexed,q.Animate,Modifier.Overridden
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")-- Modifier 타입(`Modifier<Frame>`/`ModifierInto<TextButton>` 등)은 quad_roblox 패키지 루트가 내보낸다local RobloxModule = require("@game/ReplicatedStorage/roblox_packages/quad_roblox")이 문서의 예제는 모두 위 준비 코드를 앞에 둔 상태를 가정합니다 — 파일 이름이 붙은 블록(Theme.luau·Styles.luau·ThemedButton.luau)도 각각 그 세 줄로 시작하고, 다른 파일을 쓰는 블록은 그 아래에 require 줄을 적었습니다.
1. 디자인 토큰 아키텍처
섹션 제목: “1. 디자인 토큰 아키텍처”색을 컴포넌트마다 하드코딩하는 대신, 의미 역할로 이름을 붙입니다 —
Background, Surface, TextPrimary, TextMuted, Accent, Border.
quad에는 트리를 훑어 올라가는 암묵적 컨텍스트가 없습니다. 토큰을 아래로 내리는 방법은 둘입니다.
- props로 직접 넘기기 — 층이 얕으면 이게 가장 단순합니다.
q.Context— 중간 층이 앱의 스토어 스키마를 몰라도 가방 하나를 그대로 전달할 수 있는 명시적 값 컨테이너입니다. 트리 탐색은 여전히 없습니다: 컴포넌트는 가방을 이름 붙은 파라미터로 받아 자기 키만 읽습니다.
-- (가방으로 내리는 모양 — 이 문서의 나머지 절은 층이 얕아 모듈을 직접 참조합니다)local Theme = require("./Theme")local ThemeProvider = q.Context.Provider<<typeof(Theme)>>("Theme") -- 타입 인자를 주면 :Get 결과에 타입이 붙는다
-- 앱 진입점에서 한 번(Theme은 §2에서 만드는 토큰 모듈)local ctx = q.Context():Set(ThemeProvider, Theme)
-- 아래 어딘가에서local theme = ctx:Get(ThemeProvider) -- 없으면 에러local maybe = ctx:Peek(ThemeProvider) -- 없으면 nil이 문서의 §2 이후는 모듈 직접 참조(Theme.Tokens.…) 쪽을 씁니다 — 층이 얕아 가방이 필요 없기 때문입니다. 가방으로 내리는 전체 흐름은 시작하기 15에, Context/Provider의 전체 표면은 레퍼런스: Context에 있습니다.
Context.Provider(name?)가 돌려주는 것은 테이블 신원 키입니다 — 모듈 간 문자열 충돌이 없습니다. 이름은 에러 메시지용 선택 인자입니다.:Set은 가방 자신을 변경하고 자기를 돌려줍니다(체이닝). 값이nil이면 에러입니다 — “없음”은 안 넣는 것으로 표현합니다.:Get은 없으면 에러,:Peek은nil입니다. 가방을 열거하는 표면은 일부러 없습니다.
2. 테마 토큰 만들기
섹션 제목: “2. 테마 토큰 만들기”-- Theme.luauexport type ColorPalette = { Background: Color3, Surface: Color3, TextPrimary: Color3, TextMuted: Color3, Accent: Color3, Border: Color3,}
local LIGHT: ColorPalette = { Background = Color3.fromRGB(248, 249, 250), Surface = Color3.fromRGB(255, 255, 255), TextPrimary = Color3.fromRGB(33, 37, 41), TextMuted = Color3.fromRGB(108, 117, 125), Accent = Color3.fromRGB(13, 110, 253), Border = Color3.fromRGB(222, 226, 230),}
local DARK: ColorPalette = { Background = Color3.fromRGB(18, 18, 18), Surface = Color3.fromRGB(30, 30, 30), TextPrimary = Color3.fromRGB(240, 240, 240), TextMuted = Color3.fromRGB(160, 160, 160), Accent = Color3.fromRGB(66, 153, 225), Border = Color3.fromRGB(45, 45, 45),}
local isDark = q.Source(true)
-- 지금 쓸 팔레트를 고르는 것은 한 번이면 된다local palette: q.State<ColorPalette> = isDark:Compute(function(dark) return if dark:Get() then DARK else LIGHTend)
-- 토큰 하나 = "팔레트에서 키 하나를 읽는 State"에 애니메이션을 얹은 것local function token(key: string) return palette :Apply(q.Operator.Indexed<<Color3>>(key)) :Apply(q.Animate({ Time = 0.25, Style = Enum.EasingStyle.Quad, Direction = Enum.EasingDirection.Out, }))end
return { IsDark = isDark, Toggle = function() isDark:Set(not isDark:Get()) end, Tokens = { Background = token("Background"), Surface = token("Surface"), TextPrimary = token("TextPrimary"), TextMuted = token("TextMuted"), Accent = token("Accent"), Border = token("Border"), },}:Apply(factory) — 팩토리가 만든 연산을 State에 얹는다
섹션 제목: “:Apply(factory) — 팩토리가 만든 연산을 State에 얹는다”state:Apply(factory)는 “팩토리가 만들어 준 연산을 이 State에 적용해 새 State를
얻는다”는 일반형입니다. 위 token이 그 모양을 두 번 씁니다 —
q.Operator.Indexed<<V>>(key)는 상류 값에서 키 하나를 읽어 주는 팩토리(:Compute
슈거)이고, 이어 붙인 q.Animate({...})도 같은 자리에 놓이는 팩토리입니다. 덕분에
토큰마다 function(p) return p.Background end를 손으로 하나씩 둘 필요가 없습니다.
:Apply가 State 메소드로서 갖는 계약은 레퍼런스: State에
있습니다.
Indexed의 값 타입은 키에서 추론되지 않으니 <<Color3>>처럼 호출자가 직접 적고,
없는 키는 에러 없이 nil이 되니 키 이름은 팔레트 쪽과 맞춰 두세요. 이런 이름 붙은
콤비네이터가 열넷 있습니다 — 레퍼런스: Operator.
애니메이션은 state:Apply(q.Animate{...})로 붙인다
섹션 제목: “애니메이션은 state:Apply(q.Animate{...})로 붙인다”q.Tween{...}은 한 번의 목표값을 서술하는 옵션 테이블이고, 그 Value는
plain 값이어야 합니다(State를 넣으면 에러입니다). 값이 바뀔 때마다 자동으로
보간되게 하려면 State에 q.Animate를 :Apply합니다.
- 옵션 이름은
Time/Style/Direction/RepeatCount/Reverses/DelayTime/Override(“Cancel” 또는 “Finish”) /Dedup/CanAnimate이고,Info에TweenInfo를 통째로 줄 수도 있습니다. - 옵션에도 State를 넣을 수 있지만 옵션 State가 바뀌었다고 다시 애니메이션하지는 않습니다 — 다음 값 변경 때 최신 옵션이 반영됩니다.
CanAnimate = false면 보간 없이 값이 그대로 나갑니다(모션 축소 옵션에 쓰기 좋습니다).
옵션 하나하나의 계약과 프로퍼티 자리의 3-상태 동작(첫 세팅 스냅·Dedup·Override)은
레퍼런스: Tween과 Animate에 있습니다.
3. Modifier.Overridden으로 스타일 합성
섹션 제목: “3. Modifier.Overridden으로 스타일 합성”Modifier는 불변입니다 — 모든 setter는 바깥 테이블과 필드 테이블을 둘 다
얕게 복제해서 새 Modifier를 만들고, 만들어진 Modifier는 얼어 있습니다.
그래서 공용 기본 스타일을 컴포넌트에 넘겨도 원본이 오염되지 않습니다. setter 체인과
Peek/Apply/Overridden의 전체 표면은 레퍼런스: Modifier에
있습니다.
-- Styles.luaulocal Theme = require("./Theme")
return { Button = q.Modifier "TextButton" () :Size(UDim2.new(0, 120, 0, 40)) :BackgroundColor3(Theme.Tokens.Accent) :BorderSizePixel(0),}초기 필드 테이블 형태도 같은 값입니다 — q.Modifier "TextButton" { Size = ... }. 다만 테이블 형태는 필드 이름·값 타입을 strict가 검사하지 않습니다(체인 형태만).
-- ThemedButton.luaulocal Styles = require("./Styles")
local function ThemedButton(props: { read Text: string?, read OnClick: () -> (), read Modifier: RobloxModule.ModifierInto<TextButton>?,}) return q.Elem "TextButton" { Styles.Button, -- 기본 스타일 props.Modifier or q.None, -- 호출자 오버라이드 — 뒤에 놓인 Modifier가 이긴다 Text = props.Text or "", TextColor3 = Color3.fromRGB(255, 255, 255), FontFace = Font.fromEnum(Enum.Font.GothamBold), TextSize = 14, Activated = function() props.OnClick() end, }end
return ThemedButton글꼴은
FontFace로 쓰세요. 예제가 전부FontFace = Font.fromEnum(...)인 이유는 그게 현행 API이기 때문입니다. [2026-09-09] 옛Font = Enum.Font.GothamBold도 타입 표면에 있어 타입 검사를 통과하지만, 엔진이Hidden으로 표시한 레거시라 필드에-- @deprecated주석이 붙어 있습니다 — quad v1 코드를 옮기는 동안에만 쓰고 새 코드에는FontFace를 쓰세요.
Modifier.Overridden(a, b, ...)는 필드 단위로 합치고 뒤 인자가 이깁니다. 닷 형태와 콜론 형태(a:Overridden(b))가 같은 함수입니다. 결과는 두Modifier를 숫자 키 자리에 순서대로 놓은 것(01)과 같습니다 — 합친 값을 변수 하나로 들고 다니거나 넘겨야 할 때 이쪽을 씁니다.- props로 받은
Modifier는 입력 자리 마커ModifierInto<TextButton>으로 받아 숫자 키 자리에 그대로 놓습니다 — 상위 클래스 Modifier(q.Modifier "GuiObject" ())도 들어오고 형제 클래스 것은 타입이 거부합니다. 마커에는 메소드가 없으니, 컴포넌트 안에서 setter를 불러야 하면Modifier<TextButton>으로 받으세요(그때 호출자는 상위 클래스 값을:AsTextButton()으로 내려 넘깁니다).mod:As<<T>>()는 무검사 캐스트이니 필요할 때만 쓰세요. Modifier는 숫자 키 자리에 놓습니다. 선택적이면props.Modifier or q.None관용구를 씁니다.
4. 테마가 붙은 카드 조립
섹션 제목: “4. 테마가 붙은 카드 조립”-- SettingsCard.luaulocal Theme = require("./Theme")local ThemedButton = require("./ThemedButton")
local function SettingsCard() return q.Elem "Frame" { Size = UDim2.new(0, 360, 0, 240), Position = UDim2.new(0.5, -180, 0.5, -120), BackgroundColor3 = Theme.Tokens.Surface, BorderSizePixel = 0,
UICorner = UDim.new(0, 12), -- 숏핸드: 관리 자식 UICorner를 만들어 붙인다
q.Elem "TextLabel" { Size = UDim2.new(1, -40, 0, 30), Position = UDim2.new(0, 20, 0, 20), BackgroundTransparency = 1, FontFace = Font.fromEnum(Enum.Font.GothamBold), TextSize = 18, TextXAlignment = Enum.TextXAlignment.Left, TextColor3 = Theme.Tokens.TextPrimary, Text = "화면 설정", },
q.Elem "TextLabel" { Size = UDim2.new(1, -40, 0, 20), Position = UDim2.new(0, 20, 0, 55), BackgroundTransparency = 1, FontFace = Font.fromEnum(Enum.Font.Gotham), TextSize = 13, TextXAlignment = Enum.TextXAlignment.Left, TextColor3 = Theme.Tokens.TextMuted, Text = "테마와 표시 옵션을 바꿉니다.", },
ThemedButton { Text = "테마 전환", OnClick = Theme.Toggle, Modifier = q.Modifier "TextButton" () :Position(UDim2.new(0, 20, 1, -60)) :Size(UDim2.new(0, 140, 0, 38)), }, }endParent는 props가 아닙니다 — 만든 뒤 밖에서 card.Parent = screenGui 한 줄로
붙입니다.
5. 이 구조가 주는 것
섹션 제목: “5. 이 구조가 주는 것”- 토큰 갱신 경로가 하나입니다.
isDark:Set만 바뀌면 그 토큰을 쓰는 모든 프로퍼티가 같은 경로로 갱신됩니다. - 전환이 부드럽습니다. 각 토큰이
:Apply(q.Animate{...})를 거치므로 색이 튀지 않고 보간됩니다. - 스타일 격리가 구조적으로 보장됩니다.
Modifier가 불변이라, 커스터마이즈 한 사본을 넘겨도 공용 원본이 바뀌지 않습니다.
이해 점검
섹션 제목: “이해 점검”토큰 하나가 만들어지는 길
테마 팔레트 State에서 키 하나를 읽고 거기에 애니메이션까지 붙인 토큰을 만드는 방법으로 옳은 것은 무엇인가요?
- 키 하나를 읽어 주는 팩토리를
:Apply하고, 그 위에q.Animate를 다시:Apply합니다 - 키 읽기 팩토리는 키 이름에서 값 타입을 추론하므로 타입 인자를 적지 않아도 됩니다
- 팔레트에 없는 키를 주면 그 자리에서 에러가 나므로 오타는 바로 드러납니다
state:Apply(factory)가 “팩토리가 만들어 준 연산을 이 State에 적용해 새 State를 얻는다”는 일반형이라 두 번 이어 붙습니다. 값 타입은 호출자가 직접 적어야 하고, 없는 키는 에러 없이 nil이 되니 키 이름은 팔레트 쪽과 맞춰 두어야 합니다.
Modifier를 합성할 때
공용 기본 스타일에 호출자 오버라이드를 얹는 것에 대한 설명으로 옳은 것은 무엇인가요?
- 공용 스타일을 넘겨도 원본은 바뀌지 않고, 합성에서는 뒤 인자가 같은 필드를 덮습니다
- setter가 원본을 제자리에서 고치므로 공용 스타일은 넘기기 전에 직접 복사해야 합니다
- 합성에서는 앞 인자가 이기므로 기본 스타일을 뒤에 적어야 합니다
모든 setter가 바깥 테이블과 필드 테이블을 둘 다 얕게 복제해 새 Modifier를 만들고 그 결과는 얼어 있습니다. 그래서 커스터마이즈한 사본을 넘겨도 공용 원본이 오염되지 않습니다.
퀴즈 진행 상황
0 / 0 답변한 문제 (0%)
0 정답
퀴즈 완료!
0%
0 / 0 정답
답변함: 0 / 0 (0%)