/shared:widgetbook — 화면 부품 전시장 만들기#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /shared:widgetbook |
| 별칭 | /widgetbook:add, /catalog:create |
| 모델 | sonnet |
| 사용 도구 | Read, Edit, Write, Glob, Grep |
| 연계 스킬 | flutter-ui |
한마디로#
앱에 들어가는 버튼·카드·입력창 같은 UI 부품들을 한곳에 모아 보여주는 "전시장(카탈로그)"을 만들어 주는 도우미입니다. 가구점에 가면 소파 하나를 색깔·크기별로 쭉 진열해 둔 것처럼, 우리 앱 부품도 상태별·기기별로 한눈에 모아 보게 해 줍니다.
누가·언제 쓰나요#
- 디자이너·기획자가 앱에 들어간 UI 부품이 실제로 어떻게 보이는지 직접 눌러 보며 확인하고 싶을 때
- 개발자가 새 부품(버튼, 카드 등)을 만들고 나서 전시장에 등록하고 싶을 때
/shared:widgetbook명령을 실행하거나, 부품 카탈로그·UseCase 작성 작업이 필요할 때 자동으로 동작합니다
무엇을 해주나요#
-
전시물은 각 기능(feature) 폴더가 직접 들고 있고, 전시장 앱(
app/{{project_name}}_widgetbook)은 그걸 모아서 진열만 합니다 — 가구를 만든 공방이 물건을 보관하고, 전시장은 자리만 내주는 식입니다 -
부품을 성격별로 칸을 나눠 정리합니다 — 사용자 앱 화면(
[App]), 관리자 콘솔 화면([Console]), 색상·글꼴 같은 기초 요소([Foundation]), 개발용 임시([Lab]) - 같은 부품을 여러 기기 크기(아이폰·아이패드·데스크톱)와 테마(라이트/다크), 언어(한국어·영어·일본어 등)로 바꿔 가며 볼 수 있게 설정해 줍니다
- 실제 데이터 없이도 화면이 채워져 보이도록 가짜 데이터(Mock)를 끼워 줍니다
어떻게 쓰나요#
# 전시장 앱을 브라우저(Chrome)로 실행해서 직접 둘러보기
flutter run -t lib/main.dart -d chrome
# ① 가짜 데이터(mock) 만들기 — 전시물을 소유한 feature 패키지에서
cd feature/{surface}/{feature_name} & & dart run build_runner build --delete-conflicting-outputs
# ② 전시장 목록에 반영 — 조립 앱에서 (이걸 빼먹으면 " 코드는 썼는데 안 보인다 " )
cd app/{{project_name}}_widgetbook & & dart run build_runner build --delete-conflicting-outputs
# 웹용으로 빌드(공유·배포용)
flutter build web -t lib/main.dart
명령 자체는 /shared:widgetbook 로 실행하며, 부품 이름(component_name)만 있으면 됩니다. 분류(category)와 경로(path)는 선택 사항이라 비워 두면 해당 feature 의 서피스로 등록됩니다.
안에서 무슨 일이 벌어지나요#
- 전시장 앱의 기본 골격(진입점, 기기 크기·테마·언어 선택 메뉴)을 준비합니다.
-
보여 줄 부품에 "전시 표시(@UseCase)"를 붙여, 어느 칸에 진열할지와 어떤 시안에서 온 화면인지(
designLink)를 적습니다. 파일은 그 부품을 만든 feature 폴더 안에 둡니다. - 한 부품의 여러 상태(기본·비활성·로딩 등)를 나란히 늘어놓아 비교하기 쉽게 묶습니다.
- 화면 단위 부품은 실제 데이터 대신 가짜 데이터를 넣어 깨지지 않게 채웁니다. 이때 화면이 가짜 데이터를 무시하지 않도록 바깥에서 끼워 넣을 수 있는 구조여야 합니다.
- 코드 자동 생성을 두 번(feature → 조립 앱) 돌려 전시장에 새 부품을 반영하고, 여러 기기 크기에서 잘 보이는지 점검합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Role#
Manages component catalog using Widgetbook.
- @UseCase annotation-based component showcase
- Addon configuration (Viewport, Theme, Slang)
- Feature-owned use_case files, app-assembled catalog
- Mock object setup with injectable BLoC
SoT: 규약 자체는
skills/widgetbook-conventions가 소유한다. 이 에이전트는 그 규약대로 파일을 만드는 실행자이며, 규약을 여기서 재정의하지 않는다. 설계 산출물과의 대조 계약은skills/figma-widgetbook-alignment.
Activation Conditions#
/shared:widgetbookActivated when command is invoked- Invoked during component catalog and UseCase writing
Parameters#
| Parameter | Required | Description |
|---|---|---|
component_name | ✅ | Component name (PascalCase) |
category | ❌ | app, console, foundation, lab (default: 소속 feature 의 surface) |
path | ❌ | Path within Widgetbook ([App]/Store 형태) |
Package Structure — feature owns, app assembles#
use_case 파일은 그 화면을 소유한 feature 패키지 안에 산다. 조립 앱은 @App() · addon ·
생성된 directories 만 갖는다.
feature/{surface}/{feature_name}/ ← use_case 소유
├── lib/src/presentation/page/store_page.dart
├── widgetbook/
│ ├── store_page_use_case.dart
│ └── store_page_use_case.mocks.dart # 자동 생성 (feature 에서 build_runner)
└── pubspec.yaml # widgetbook_annotation 의존
app/{{project_name}}_widgetbook/ ← 조립 sink. use_case 파일 없음
├── lib/
│ ├── main.dart # @App() + addon 구성
│ ├── main.directories.g.dart # 자동 생성 — feature 들을 스캔한 결과
│ └── add_on/ # 커스텀 Addon
│ ├── add_on.dart
│ ├── slang_addon.dart
│ ├── view_ports.dart
│ └── widgetbook_group.dart
└── pubspec.yaml # 모든 feature + widgetbook + generator 의존| 규칙 | 내용 |
|---|---|
| feature 의존 | widgetbook_annotation 만 (어노테이션 전용 L0 리프). 계층 위반이 아니다 |
| 조립 앱 의존 | widgetbook 본체 · widgetbook_generator · 모든 feature |
| 금지 | feature 가 조립 앱을 의존하는 것. 조립 앱은 순수 sink 다 |
근거는 skills/package-layers L7 참조.
Import Order (Required)#
// 1. Flutter standard
import 'package:flutter/material.dart' as material;
// 2. Widgetbook package
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
// 3. UI Kit (CoUI)
import 'package:coui_flutter/coui_flutter.dart';
// 4. Internal modules
import 'add_on/add_on.dart';
import 'main.directories.g.dart';
Core Patterns#
1. Main App Setup#
import 'package:flutter/material.dart' as material;
import 'package:i10n/i10n.dart';
import 'package:resources/resources.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
import 'add_on/add_on.dart';
import 'main.directories.g.dart';
/// Widgetbook 앱 진입점
@App()
class WidgetbookApp extends material.StatelessWidget {
/// WidgetbookApp 생성자
const WidgetbookApp({super.key});
@override
material.Widget build(material.BuildContext context) {
return Widgetbook.material(
// 초기 라우트
initialRoute: '/StorePage',
// 자동 생성된 디렉토리
directories: directories,
// Addon 구성
addons: [
// 뷰포트 선택
ViewportAddon(Viewports.all),
// 위젯 인스펙터
InspectorAddon(),
// 다국어 지원
SlangAddon(
locales: AppLocaleUtils.supportedLocales,
localeNames: {
const material.Locale('en'): 'English',
const material.Locale('ko'): '한국어',
const material.Locale('ja'): '日本語',
// ... 추가 로케일
},
),
// 테마 선택
MaterialThemeAddon(
themes: [
WidgetbookTheme(
name: 'Light',
data: AppTheme.light,
),
WidgetbookTheme(
name: 'Dark',
data: AppTheme.dark,
),
],
),
// 정렬 옵션
AlignmentAddon(initialAlignment: material.Alignment.topLeft),
// 텍스트 스케일
TextScaleAddon(initialScale: 1),
// SafeArea 래퍼
BuilderAddon(
name: 'SafeArea',
builder: (context, child) => material.SafeArea(child: child),
),
],
);
}
}
/// 앱 메인 함수
void main() {
material.runApp(const WidgetbookApp());
}
2. UseCase Annotation Pattern#
import 'package:coui_flutter/coui_flutter.dart';
import 'package:flutter/material.dart' as material;
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
import 'add_on/widgetbook_group.dart';
/// Button 컴포넌트 UseCase
@UseCase(
name: 'Button',
type: Button,
path: '[Foundation]/Button',
)
material.Widget buildWidgetbookButtonUseCase(material.BuildContext context) {
return WidgetbookGroup(
label: 'Unibook Button',
children: [
// Primary Button
WidgetbookButton(
label: 'Primary Button',
button: Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: const Text('Primary'),
),
),
// Secondary Button
WidgetbookButton(
label: 'Secondary Button',
button: Button(
variant: CoreButtonVariant.secondary,
onPressed: () {},
child: const Text('Secondary'),
),
),
// Outlined Button
WidgetbookButton(
label: 'Outlined Button',
button: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: const Text('Outlined'),
),
),
// Disabled Button
WidgetbookButton(
label: 'Disabled Button',
button: Button(
variant: CoreButtonVariant.primary,
onPressed: null,
child: const Text('Disabled'),
),
),
// Loading Button
WidgetbookButton(
label: 'Loading Button',
button: Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: const Text('Loading'),
),
),
],
);
}
3. WidgetbookGroup Helper#
import 'package:flutter/material.dart' as material;
/// Widgetbook 그룹 컨테이너
class WidgetbookGroup extends material.StatelessWidget {
/// WidgetbookGroup 생성자
const WidgetbookGroup({
required this.label,
required this.children,
super.key,
});
/// 그룹 라벨
final String label;
/// 자식 위젯들
final List<material.Widget> children;
@override
material.Widget build(material.BuildContext context) {
return material.SingleChildScrollView(
padding: const material.EdgeInsets.all(16),
child: material.Column(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
material.Text(
label,
style: const material.TextStyle(
fontSize: 24,
fontWeight: material.FontWeight.bold,
),
),
const material.SizedBox(height: 16),
...children.map((child) => material.Padding(
padding: const material.EdgeInsets.only(bottom: 16),
child: child,
)),
],
),
);
}
}
/// Widgetbook 버튼 항목
class WidgetbookButton extends material.StatelessWidget {
/// WidgetbookButton 생성자
const WidgetbookButton({
required this.label,
required this.button,
super.key,
});
/// 버튼 라벨
final String label;
/// 버튼 위젯
final material.Widget button;
@override
material.Widget build(material.BuildContext context) {
return material.Column(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
material.Text(
label,
style: const material.TextStyle(
fontSize: 14,
color: material.Colors.grey,
),
),
const material.SizedBox(height: 8),
button,
],
);
}
}
4. Slang Addon#
import 'package:flutter/material.dart' as material;
import 'package:i10n/i10n.dart';
import 'package:widgetbook/widgetbook.dart';
/// Slang 다국어 Addon
class SlangAddon extends WidgetbookAddon<material.Locale> {
/// SlangAddon 생성자
SlangAddon({
required this.locales,
required this.localeNames,
material.Locale? initialLocale,
}) : super(
name: 'Locale',
initialSetting: initialLocale ?? locales.first,
);
/// 지원 로케일 목록
final List<material.Locale> locales;
/// 로케일별 표시 이름
final Map<material.Locale, String> localeNames;
@override
List<Field> get fields => [
ListField<material.Locale>(
name: 'Locale',
values: locales,
initialValue: initialSetting,
labelBuilder: (locale) =>
localeNames[locale] ?? locale.languageCode,
),
];
@override
material.Locale valueFromQueryGroup(Map<String, String> group) {
final localeCode = group['Locale'];
return locales.firstWhere(
(locale) => locale.languageCode == localeCode,
orElse: () => locales.first,
);
}
@override
material.Widget buildUseCase(
material.BuildContext context,
material.Widget child,
material.Locale setting,
) {
return TranslationProvider(
child: material.Builder(
builder: (context) {
// 로케일 변경
LocaleSettings.setLocale(
AppLocale.values.firstWhere(
(l) => l.languageCode == setting.languageCode,
orElse: () => AppLocale.en,
),
);
return child;
},
),
);
}
}
5. Viewports Definition#
import 'package:widgetbook/widgetbook.dart';
/// 뷰포트 정의
abstract final class Viewports {
/// 모든 뷰포트
static const List<Device> all = [
// 모바일
Device.phone(name: 'iPhone SE', resolution: Resolution(width: 375, height: 667)),
Device.phone(name: 'iPhone 14', resolution: Resolution(width: 390, height: 844)),
Device.phone(name: 'iPhone 14 Pro Max', resolution: Resolution(width: 430, height: 932)),
Device.phone(name: 'Android Small', resolution: Resolution(width: 360, height: 640)),
Device.phone(name: 'Android Large', resolution: Resolution(width: 412, height: 915)),
// 태블릿
Device.tablet(name: 'iPad Mini', resolution: Resolution(width: 744, height: 1133)),
Device.tablet(name: 'iPad Pro 11"', resolution: Resolution(width: 834, height: 1194)),
Device.tablet(name: 'iPad Pro 12.9"', resolution: Resolution(width: 1024, height: 1366)),
// 데스크톱
Device.desktop(name: 'Desktop HD', resolution: Resolution(width: 1280, height: 720)),
Device.desktop(name: 'Desktop FHD', resolution: Resolution(width: 1920, height: 1080)),
Device.desktop(name: 'Desktop 4K', resolution: Resolution(width: 3840, height: 2160)),
];
/// 모바일 뷰포트만
static const List<Device> mobile = [
Device.phone(name: 'iPhone SE', resolution: Resolution(width: 375, height: 667)),
Device.phone(name: 'iPhone 14', resolution: Resolution(width: 390, height: 844)),
Device.phone(name: 'Android', resolution: Resolution(width: 412, height: 915)),
];
/// 태블릿 뷰포트만
static const List<Device> tablet = [
Device.tablet(name: 'iPad Mini', resolution: Resolution(width: 744, height: 1133)),
Device.tablet(name: 'iPad Pro', resolution: Resolution(width: 1024, height: 1366)),
];
}
6. Page UseCase Example — 상태 Knob 가 실제로 동작하는 형태#
파일 위치는 feature/{surface}/home/widgetbook/home_page_use_case.dart 다 (조립 앱 아님).
⚠️
BlocSignalProvider(create:)로 감싸고const HomePage()를 자식으로 두면 Knob 이 무력화된다. Page 내부가 같은 타입의 BLoC 을 자체 생성해 바깥에서 준 mock 을 가려버리기 때문이다. 실측에서 100개 Page 중 82개가 이 상태였다 —widgetbook-conventions §2참조. Page 가this.bloc선택 주입을 지원하도록 먼저 고친 뒤 아래 형태로 쓴다.
import 'package:feature_home/feature_home.dart';
import 'package:flutter/material.dart' as material;
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
import 'home_page_use_case.mocks.dart';
/// 상태 어휘 — 스캐너가 파싱하므로 상수로 선언한다.
const _stateOptions = ['로딩', '데이터', '빈', '에러'];
@GenerateNiceMocks([MockSpec<HomeBloc>()])
@UseCase(
name: '홈', // 설계 섹션 이름과 동일 문자열
type: HomePage,
path: '[App]/Home', // 대괄호는 세그먼트 하나를 감싼다 ('[App/Home]' 아님)
designLink: 'https://www.figma.com/design/<key>/...?node-id=1234-5678',
)
material.Widget buildWidgetbookHomePageUseCase(material.BuildContext context) {
final stateLabel = context.knobs.object.dropdown<String>(
label: '상태',
options: _stateOptions,
initialOption: '데이터',
labelBuilder: (value) => value,
);
final state = switch (stateLabel) {
'로딩' => const HomeState(status: HomeStatusLoading()),
'에러' => const HomeState(status: HomeStatusError('불러오지 못했습니다')),
'빈' => const HomeState(status: HomeStatusLoaded(), items: []),
_ => const HomeState(
status: HomeStatusLoaded(),
items: [
HomeItem(id: 1, title: 'Item 1'),
HomeItem(id: 2, title: 'Item 2'),
],
),
};
final bloc = MockHomeBloc();
when(bloc.state).thenReturn(state);
when(bloc.stream).thenAnswer((_) => Stream.value(state));
when(bloc.add(any)).thenReturn(null);
// ✅ Page 가 this.bloc 주입을 지원하므로 mock 이 실제로 사용된다.
return HomePage(bloc: bloc);
}
플랫폼(Mobile·Tablet·Web)은 Device Addon 이 담당한다. 화면 폭으로 UseCase 를 늘리지 않는다.
7. Foundation UseCase Example#
import 'package:flutter/material.dart' as material;
import 'package:resources/resources.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
/// Colors UseCase
@UseCase(
name: 'Colors',
type: material.ColorScheme,
path: '[Foundation]',
)
material.Widget buildWidgetbookColorsUseCase(material.BuildContext context) {
final colorScheme = material.Theme.of(context).colorScheme;
return material.SingleChildScrollView(
padding: const material.EdgeInsets.all(16),
child: material.Column(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
_ColorTile(name: 'Primary', color: colorScheme.primary),
_ColorTile(name: 'On Primary', color: colorScheme.onPrimary),
_ColorTile(name: 'Secondary', color: colorScheme.secondary),
_ColorTile(name: 'On Secondary', color: colorScheme.onSecondary),
_ColorTile(name: 'Surface', color: colorScheme.surface),
_ColorTile(name: 'On Surface', color: colorScheme.onSurface),
_ColorTile(name: 'Error', color: colorScheme.error),
_ColorTile(name: 'On Error', color: colorScheme.onError),
],
),
);
}
class _ColorTile extends material.StatelessWidget {
const _ColorTile({required this.name, required this.color});
final String name;
final material.Color color;
@override
material.Widget build(material.BuildContext context) {
return material.Padding(
padding: const material.EdgeInsets.only(bottom: 8),
child: material.Row(
children: [
material.Container(
width: 48,
height: 48,
decoration: material.BoxDecoration(
color: color,
borderRadius: material.BorderRadius.circular(8),
border: material.Border.all(color: material.Colors.grey),
),
),
const material.SizedBox(width: 16),
material.Text(name),
],
),
);
}
}
Build Commands#
코드 생성은 두 곳에서 돈다. mocks 는 use_case 와 같은 패키지(= feature)에서, directories 는 조립 앱에서 생성된다.
# Run Widgetbook (조립 앱에서)
flutter run -t lib/main.dart -d chrome
# ① mocks 생성 — use_case 를 소유한 feature 패키지
cd feature/{surface}/{feature_name} & & dart run build_runner build --delete-conflicting-outputs
# ② directories 생성 — 조립 앱. 빼먹으면 use_case 가 트리에 나타나지 않는다
cd app/{{project_name}}_widgetbook & & dart run build_runner build --delete-conflicting-outputs
# Web build
flutter build web -t lib/main.dartReference Files#
feature/{surface}/{feature_name}/widgetbook/{page}_use_case.dart # 전시물 — feature 소유
app/{{project_name}}_widgetbook/lib/main.dart # @App() + addon
app/{{project_name}}_widgetbook/lib/add_on/slang_addon.dart
app/{{project_name}}_widgetbook/lib/add_on/view_ports.dartChecklist#
- @App() Annotation Apply (조립 앱)
- use_case 파일을 feature 패키지 안에 작성 (조립 앱 아님)
- feature pubspec 에
widgetbook_annotation의존 추가 - @UseCase 에
name·type·path·designLink작성 path가[App]/Feature형태인가 ([App/Feature]는 트리가 깨진다)name이 설계 섹션 이름과 같고 기존 use_case 와 중복되지 않는가- Page 가
this.bloc선택 주입을 지원하는가 (미지원이면 Knob 이 장식이 된다) - 상태는 dropdown 단일 Knob, 플랫폼은 Device Addon
- Configure Addons (Viewport, Theme, Slang)
- build_runner 를 feature · 조립 앱 양쪽에서 실행
- Test across various viewports
Related Documents#
- widgetbook-conventions — 규약 SoT
- figma-widgetbook-alignment — 설계 대조 계약
- package-layers — 조립 앱이 L7 순수 sink 인 이유
- Resources Agent
- Presentation Layer Agent