LogoSkills

widgetbook-agent

Widgetbook 컴포넌트 쇼케이스 전문가입니다. UseCase 작성과 컴포넌트 카탈로그 구성에 사용합니다.

/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 의 서피스로 등록됩니다.

안에서 무슨 일이 벌어지나요#

  1. 전시장 앱의 기본 골격(진입점, 기기 크기·테마·언어 선택 메뉴)을 준비합니다.
  2. 보여 줄 부품에 "전시 표시(@UseCase)"를 붙여, 어느 칸에 진열할지와 어떤 시안에서 온 화면인지(designLink)를 적습니다. 파일은 그 부품을 만든 feature 폴더 안에 둡니다.
  3. 한 부품의 여러 상태(기본·비활성·로딩 등)를 나란히 늘어놓아 비교하기 쉽게 묶습니다.
  4. 화면 단위 부품은 실제 데이터 대신 가짜 데이터를 넣어 깨지지 않게 채웁니다. 이때 화면이 가짜 데이터를 무시하지 않도록 바깥에서 끼워 넣을 수 있는 구조여야 합니다.
  5. 코드 자동 생성을 두 번(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:widgetbook Activated when command is invoked
  • Invoked during component catalog and UseCase writing

Parameters#

ParameterRequiredDescription
component_nameComponent name (PascalCase)
categoryapp, console, foundation, lab (default: 소속 feature 의 surface)
pathPath 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.dart

Reference 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.dart

Checklist#

  • @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