Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

StorybookのDocsを自動生成に頼らず泥臭くリッチに手書きする方法

モチベーション

前略、ヤプリ #1 Advent Calendar 2024の18日目の記事です。shadcn/ui(Radix UI)などコンポジションパターンでの利用が前提のライブラリは次の理由からStorybookのドキュメントの自動生成に向きません。

  1. PropsがReact.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>のように導出されていて、パーサーが型定義から情報を得られない。
  2. 利用側で組み立てていくので、単一のコンポーネントを期待するStorybookのインターフェイスとずれがある。

それでもコンポジションする複数のパーツのPropsをArgsTable上に表示できたら嬉しいですよね。手書きするしかないですね(勧めていません)。

前置き

基本方針

shadcn/uiのコンポーネントを例としてStorybookを順に構築していきます。 あくまで書き方の一例なので無理矢理感はその通りです。

  • 対象はshadcn/uiのToggleGroup
  • Storybookファイル(toggle-group.stories.tsx)のみ編集
  • 操作可能かつDocsに主要なPropsが記載されている状態を目指す

想定読者

Storybookと溺死したい人

書くこと

Storybookのドキュメントのニッチな書き方

書かないこと

  • 詳細な環境構築・ツールの設定
  • 背景・選定理由
  • Storybook・TypeScript・Reactの基本

完成形

完成形のStorybookのスクリーンショット
完成形のStorybookのスクリーンショット

最終コード

import { Meta } from "@storybook/react";
import { Bold, Italic, Underline } from "lucide-react";

import { ToggleGroup, ToggleGroupItem } from "./toggle-group";

type ToggleGroupProps = React.ComponentProps<typeof ToggleGroup>;

type Size = ToggleGroupProps["size"];
type Variant = ToggleGroupProps["variant"];
type Type = ToggleGroupProps["type"];

const SIZES = ["default", "sm", "lg"] as const satisfies Size[];
const VARIANTS = ["default", "outline"] as const satisfies Variant[];
const TYPES = ["single", "multiple"] as const satisfies Type[];

/**
 * A set of two-state buttons that can be toggled on or off.
 *
 * ## リンク
 *
 * - [shadcn/ui](https://ui.shadcn.com/docs/components/toggle-group)
 * - [Radix UI](https://www.radix-ui.com/primitives/docs/components/toggle-group)
 *
 */
const meta: Meta = {
  title: "Components/ToggleGroup",
  tags: ["autodocs"],
  args: {
    type: "multiple",
    size: "default",
    variant: "default",
    _rootAriaLabel: "Text format",
  },
  argTypes: {
    type: {
      description: "タイプ",
      type: {
        name: "string",
        required: true,
      },
      table: {
        type: {
          summary: TYPES.join("|"),
        },
        category: "Root",
      },
      control: { type: "radio" },
      options: TYPES,
    },
    size: {
      description: "サイズ",
      table: {
        type: {
          summary: SIZES.join("|"),
        },
        defaultValue: { summary: "default" },
        category: "Root",
      },
      control: { type: "radio" },
      options: SIZES,
    },
    variant: {
      description: "バリアント",
      table: {
        type: {
          summary: VARIANTS.join("|"),
        },
        defaultValue: { summary: "default" },
        category: "Root",
      },
      control: { type: "radio" },
      options: VARIANTS,
    },
    _rootDisabled: {
      name: "disabled",
      description: "非活性フラグ",
      table: {
        type: {
          summary: "boolean",
        },
        category: "Root",
      },
      control: { type: "boolean" },
    },
    _singleDefaultValue: {
      name: "defaultValue",
      description: "デフォルト値(`single`)",
      table: {
        type: {
          summary: "string",
        },
        category: "Root",
      },
      options: ["bold", "italic", "strikethrough"],
      control: { type: "radio" },
    },
    _multipleDefaultValue: {
      name: "defaultValue",
      description: "デフォルト値(`multiple`)",
      table: {
        type: {
          summary: "string[]",
        },
        category: "Root",
      },
      options: ["bold", "italic", "strikethrough"],
      control: { type: "check" },
    },
    _rootAriaLabel: {
      name: "aria-label",
      description: "アクセシビリティラベル",
      type: {
        name: "string",
        required: false,
      },
      table: {
        category: "Root",
      },
    },
    value: {
      description: "値",
      type: {
        name: "string",
        required: true,
      },
      table: {
        category: "Item",
      },
    },
    _itemDisabled: {
      name: "disabled",
      description: "非活性フラグ",
      table: {
        type: {
          summary: "boolean",
        },
        category: "Item",
      },
      control: { type: "boolean" },
    },
    _itemAriaLabel: {
      name: "aria-label",
      description: "アクセシビリティラベル",
      type: {
        name: "string",
        required: true,
      },
      table: {
        category: "Item",
      },
    },
  },
};
export default meta;

type RootArgs = {
  size?: Size;
  variant?: Variant;
  _rootDisabled?: boolean;
  _rootAriaLabel?: string;
} & (
  | {
      type: "multiple";
      _multipleDefaultValue?: string[];
      _singleDefaultValue?: never;
    }
  | {
      type: "single";
      _multipleDefaultValue?: never;
      _singleDefaultValue?: string;
    }
);

type ItemArgs = {
  _itemDisabled?: boolean;
  _itemAriaLabel: string;
};

type Args = RootArgs & ItemArgs;

export const Default = {
  render: (args: Args) => {
    const { _itemDisabled, _itemAriaLabel, ...rest } = args;
    const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest;
    const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const;
    const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const;
    const rootProps = {
      ...rootRest,
      disabled: _rootDisabled,
      "aria-label": _rootAriaLabel,
    };
    const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel };

    return (
      <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}>
        <ToggleGroupItem value="bold" aria-label="Toggle bold">
          <Bold className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="Toggle italic">
          <Italic className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="strikethrough" {...itemProps}>
          <Underline className="h-4 w-4" />
        </ToggleGroupItem>
      </ToggleGroup>
    );
  },
};

github.com

参考リンク

作り方

環境構築

環境

2024/12/18時点

Package Version
node 22.11.0
next 15.0.4
react ^19.0.0
react-dom ^19.0.0
storybook ^8.4.7
tailwindcss ^3.4.1
typescript ^5

手順メモ

初期化

Next.js

pnpm create next-app@latest storybook-hand-writing
  • Tailwind CSS: Y
  • src/ directory: Y
  • import alias: N

shadcn/ui

pnpm dlx shadcn@latest init -d

Storybook

pnpm dlx storybook init

app/storybookを消去。 .storybook/previewに追記。

import "@/app/globals.css";

パッケージ追加とアップデート

pnpm add -D eslint-plugin-tailwindcss @types/node@latest prettier@latest

npm scriptsやリンター設定を整理して最終的にこうなりました。

package.json

{
    "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint && prettier --check .",
    "format": "next lint --fix && prettier --write .",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  },
  "dependencies": {
    "class-variance-authority": "0.7.1",
    "clsx": "2.1.1",
    "lucide-react": "0.468.0",
    "next": "15.0.4",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwind-merge": "2.5.5",
    "tailwindcss-animate": "1.0.7"
  },
  "devDependencies": {
    "@chromatic-com/storybook": "^3.2.2",
    "@storybook/addon-essentials": "^8.4.7",
    "@storybook/addon-interactions": "^8.4.7",
    "@storybook/addon-onboarding": "^8.4.7",
    "@storybook/blocks": "^8.4.7",
    "@storybook/nextjs": "^8.4.7",
    "@storybook/react": "^8.4.7",
    "@storybook/test": "^8.4.7",
    "@types/node": "^22.10.1",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^8",
    "eslint-config-next": "15.0.4",
    "eslint-config-prettier": "9.1.0",
    "eslint-plugin-storybook": "0.11.1",
    "eslint-plugin-tailwindcss": "3.17.5",
    "postcss": "^8",
    "prettier": "3.4.2",
    "storybook": "^8.4.7",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

.eslintrc.json

{
  "extends": [
    "next/core-web-vitals",
    "next/typescript",
    "plugin:storybook/recommended",
    "plugin:tailwindcss/recommended",
    "prettier"
  ]
}

.prettierignore

pnpm-lock.yaml

フォーマットをかけます。prettierの設定はお好みで。

pnpm format

Storybookに表示するまで

pnpm dlx shadcn@latest add toggle-group

コンポーネントと同階層にtoggle-group.stories.tsxを作ります。サンプルコードはshadcn/uiから借用します。

toggle-group.stories.tsx

import { Meta, StoryObj } from "@storybook/react";
import { Bold, Italic, Underline } from "lucide-react";

import { ToggleGroup, ToggleGroupItem } from "./toggle-group";

const meta: Meta<typeof ToggleGroup> = {
  title: "Components/ToggleGroup",
  component: ToggleGroup,
  tags: ["autodocs"],
};
export default meta;

type Story = StoryObj<typeof ToggleGroup>;

export const Default: Story = {
  args: {
    type: "multiple",
    defaultValue: [],
    size: "default",
    variant: "default",
  },
  render: (args) => {
    return (
      <ToggleGroup type="multiple">
        <ToggleGroupItem value="bold" aria-label="Toggle bold">
          <Bold className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="Toggle italic">
          <Italic className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
          <Underline className="h-4 w-4" />
        </ToggleGroupItem>
      </ToggleGroup>
    );
  },
};

render関数について render関数はレンダリングするコンポーネントを実装者が柔軟に指定できるのでこれを使います。デフォルトでは、argsにはmetaで指定したコンポーネントのPropsが詰まっています。fyi: Component Story Format (CSF) | Storybook docs

この状態でStorybookを起動します。ToggleGroupのインストール前からStorybookのサーバーを起動していた場合は再起動してください。表示されていればOKです。

pnpm storybook

Storybookのドキュメントページのスタート状態
スタートの状態

すごくプレーンなものが表示されました。

Storyを操作可能にする

次にDefaultのStoryを操作可能にします。

  render: (args) => {
    return (
      <ToggleGroup {...args}>

type,defaultValue, size, variantToggleGroupのPropsとして渡り、 Storyを操作できるようになりました。Toggleの実装を参考にvariantsizeを変えればレンダー中のコンポーネントに反映されていることを確認できます。

とはいえ、variantdefaultoutlineを持つことはStorybookを見ただけではわかりませんし、変更するにもテキストとして入力せざるを得ないのは怠いです。説明不足を補うために次はargTypesを書きます。本題です。

本題

ArgsTableの基本的な書き方

Storybookに手書きで理解させます。

const SIZES = ["default", "sm", "lg"] as const satisfies React.ComponentProps<typeof ToggleGroup>["size"][];
const VARIANTS = ["default", "outline"] as const satisfies React.ComponentProps<typeof ToggleGroup>["variant"][];
const TYPES = ["single", "multiple"] as const satisfies React.ComponentProps<typeof ToggleGroup>["type"][];

export const Default: Story = {
  // ...args
  argTypes: {
    type: {
      description: "タイプ",
      table: {
        type: {
          summary: TYPES.join("|"),
        },
        defaultValue: { summary: "md" },
      },
      control: { type: "radio" },
      options: TYPES,
    },
    size: {
      description: "サイズ",
      table: {
        type: {
          summary: SIZES.join("|"),
        },
        defaultValue: { summary: "md" },
      },
      control: { type: "radio" },
      options: SIZES,
    },
    variant: {
      description: "バリアント",
      table: {
        type: {
          summary: VARIANTS.join("|"),
        },
        defaultValue: { summary: "default" },
      },
      control: { type: "radio" },
      options: VARIANTS,
    },
  },
  // ...render

このように書けば、画像のようになっていないかもしれません。デフォルト値がmdなのはコピペしたものを修正し忘れました。適宜defaultに読み替えてください。

Storybookのドキュメントページ、argTypesを追加して情報量が少し増えている
argTypesの追加

必須のマークを追加

typeは必須指定なのでArgsTable上で表現しましょう。

    type: {
      description: "タイプ",
      type: {
        name: "string",
        required: true,
      },

typeばっかりで紛らわしいですね。コンポーネントのPropsではなくArgsTableの設定値のtypetableの方にも同じプロパティがあるので挿入位置に注意しましょう。

Storybookのドキュメントページ、ArgsTable内で必須のPropsが判断できるようにマークが付いている
必須のPropsが判断できるようになった

必須であることがわかるようになりました。disabledも同じ要領で追加します。

カテゴリー分け

ToggleGroupのPropsについてしか記述していないのでToggleGroupItemのPropsも表示させたいです。さて、ToggleGroupToggleGroupItemのPropsをどう分けましょう。table.categoryに対してカテゴリーを指定すれば実現できます。

    defaultValue: {
      description: "デフォルト値",
      table: {
        type: {
          summary: "string[]",
        },
        category: "Root",
      },
    },

Storybookのドキュメントページ、ToggleGroupのPropsをカテゴリーで分類している
ToggleGroupのPropsをカテゴリーで分類できました

カテゴリーが表示できましたね。

共通の情報をmetaに移動

ここまで個別のStory Objectの中に書いてきましたが、デフォルトのargsargTypesはStory全体に適用する指定の方が個別のStoryの見通しが良くなるでしょう。metaの中にargs,argTypesを移動させます。

const meta: Meta<typeof ToggleGroup> = {
  ...,
  args: {...},
  argTypes: {...}
}

型定義の変更

ToggleGroupItemの方のPropsも追加したいですが、問題があって、disabledToggleGroupargTypesのキーとバッティングしてしまいます。回避策としてキーとは別に表示名を指定できます。ここではToggleGroupの方を_rootDisabledToggleGroupItemの方を_itemDisabledというキーにします。ただ、このまま指定しようとすると型エラーが出ると思います。Storyの型定義が単一コンポーネント前提なので仕方ないところがあります。思い切ってStorybook型定義を省略してしまいましょう。 (苦しくなってきました。)

const meta: Meta = {

export const Default = {

今度はrender関数のargsanyになっています。CSF記法で普通に書くと、コンポーネントのpropsとStorybookのargsの型定義は一致します。普通の書き方をしてないので、堅牢性は劣るのですがStorybookのargsとコンポーネントのpropsは別個のものと考えましょう。argsに自由に型定義を注釈することで矛盾を消し去ります。関連箇所もろとも修正します。

また、<ToggleGroup {...args}>のところでもエラーが出るのでargsToggleGroupに渡すやつとToggleGroupItemに渡すやつに分割して整理します。

type ToggleGroupProps = React.ComponentProps<typeof ToggleGroup>;

type Size = ToggleGroupProps["size"];
type Variant = ToggleGroupProps["variant"];
type Type = ToggleGroupProps["type"];

const SIZES = ["default", "sm", "lg"] as const satisfies Size[];
const VARIANTS = ["default", "outline"] as const satisfies Variant[];
const TYPES = ["single", "multiple"] as const satisfies Type[];

type RootArgs = {
  size?: Size;
  variant?: Variant;
  _rootDisabled?: boolean;
} & (
  | {
      type: "multiple";
      defaultValue?: string[];
    }
  | {
      type: "single";
      defaultValue?: string;
    }
);

type ItemArgs = {
  _itemDisabled?: boolean;
};

type Args = RootArgs & ItemArgs;

export const Default = {
  render: (args: Args) => {
    const { _itemDisabled, ...rest } = args;
    const { _rootDisabled,...rootRest } = rest;
    const rootProps = { ...rootRest, disabled: _rootDisabled };
    const itemProps = { disabled: _itemDisabled };

    return (
      <ToggleGroup {...rootProps}>
        <ToggleGroupItem value="bold" aria-label="Toggle bold">
          <Bold className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="Toggle italic">
          <Italic className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough" {...itemProps}>
          <Underline className="h-4 w-4" />
        </ToggleGroupItem>
      </ToggleGroup>
    );
  },
};

パーツのPropsを追加

続けてmeta.argTypesToggleGroupItemのカテゴリとProps(value, disabled, aria-label)を追加します。

  argTypes: {
    ...
    _rootAriaLabel: {
      name: "aria-label",
      description: "アクセシビリティラベル",
      type: {
        name: "string",
        required: false,
      },
      table: {
        category: "Root",
      },
    },
    value: {
      description: "値",
      type: {
        name: "string",
        required: true,
      },
      table: {
        category: "Item",
      },
    },
    _itemDisabled: {
      name: "disabled",
      description: "非活性フラグ",
      table: {
        type: {
          summary: "boolean",
        },
        category: "Item",
      },
      control: { type: "boolean" },
    },
    _itemAriaLabel: {
      name: "aria-label",
      description: "アクセシビリティラベル",
      type: {
        name: "string",
        required: true,
      },
      table: {
        category: "Item",
      },
    },
  },
  
type RootArgs = {
  size?: Size;
  variant?: Variant;
  _rootDisabled?: boolean;
  _rootAriaLabel?: string;
} & (  
...

type ItemArgs = {
  _itemDisabled?: boolean;
  _itemAriaLabel: string;
};

export const Default = {
  render: (args: Args) => {
    const { _itemDisabled, _itemAriaLabel, ...rest } = args;
    const { _rootDisabled, _rootAriaLabel, ...rootRest } = rest;
    const rootProps = { ...rootRest, disabled: _rootDisabled, "aria-label": _rootAriaLabel };
    const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel };
    ...

Storybookのドキュメントページ、ToggleGroupとToggleGroupItemのそれぞれのPropsがカテゴリーごとに分類されている
ToggleGroupとToggleGroupItemのそれぞれのPropsをカテゴリーごとに分類

画像のようにToggleGroupToggleGroupItemに渡せるPropsがわかりやすくなりました。

依存関係のあるPropsとコントロールの修正

defaultValueも修正します。type="single"の場合とtype="multiple"の場合でdefaultValuestringを取るかstring[]を取るか決まります。typeの値を見て流し込むPropsを絞り込む感じです。また、配列の要素を直接指定するコントロールからチェックボックスのコントロールに変更します。

const meta: Meta = {
    ...
   _singleDefaultValue: {
      name: "defaultValue",
      description: "デフォルト値(`single`)",
      table: {
        type: {
          summary: "string",
        },
        category: "Root",
      },
      options: ["bold", "italic", "strikethrough"],
      control: { type: "radio" },
    },
    _multipleDefaultValue: {
      name: "defaultValue",
      description: "デフォルト値(`multiple`)",
      table: {
        type: {
          summary: "string[]",
        },
        category: "Root",
      },
      options: ["bold", "italic", "strikethrough"],
      control: { type: "check" },
    },
    ...
};
    
type RootArgs = {
  size?: Size;
  variant?: Variant;
  _rootDisabled?: boolean;
  _rootAriaLabel?: string;
} & (
  | {
      type: "multiple";
      _multipleDefaultValue?: string[];
      _singleDefaultValue?: never;
    }
  | {
      type: "single";
      _multipleDefaultValue?: never;
      _singleDefaultValue?: string;
    }
);

export const Default = {
  render: (args: Args) => {
    const { _itemDisabled, _itemAriaLabel, ...rest } = args;
    const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest;
    const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const;
    const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const;
    const rootProps = {
      ...rootRest,
      disabled: _rootDisabled,
      "aria-label": _rootAriaLabel,
    };
    const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel };

    return (
      <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}>
      ...

Storybookのドキュメントページ、ArgsTable内でToggleGroupのPropsの2種類のdefaultValueのインターフェイスにに対応
2種類のdefaultValueのインターフェイスに対応

結果は画像のとおりです。この状態からDocsではなくDefaultのページで任意のdefaultValueを選択したあとリロードすると初期値として選択されているはずです。

概要の追記

タイトル直下にリンクと説明を追加します。metaにJSDocで注釈すると表示できます。もちろんマークダウンが使えます。

/**
 * A set of two-state buttons that can be toggled on or off.
 *
 * ## リンク
 *
 * - [shadcn/ui](https://ui.shadcn.com/docs/components/toggle-group)
 * - [Radix UI](https://www.radix-ui.com/primitives/docs/components/toggle-group)
 *
 */
const meta: Meta = {

Storybookのドキュメントページ、タイトルの下にコンポーネントの説明とリンクが追加されています
タイトルの下にコンポーネントの説明とリンクが追加された

これで終わりです。完成形へ

Tips

マークダウンの書けるところ metaへの注釈やargTypesの各argsdescriptionに書くことができます。

関数Propの書き方 ArgsTable内でもリンクを設定できます。descriptionにマークダウンで書きます。table.typesummaryに加えてdetailを書くことでdetails/summary要素のような折りたたみにできます。関数の詳しい説明やシグネイチャーを書いたりできるでしょう。

  argTypes: {
    ...
    onValueChange: {
      description:
        "値変更時のハンドラ([API Reference](https://www.radix-ui.com/primitives/docs/components/toggle-group#root))",
      table: {
        category: "Root",
        type: {
          summary: "function",
          detail: "(value: string[]) => void",
        },
      },
    },

Storybookのドキュメントページ、関数Propの書き方の説明、参考リンクやシグネイチャーや使用法を記述しています
参考リンクやシグネイチャーも記載

テンプレート化して他のStoryで使い回すには

Defaultの中身を関数に切り出して、Story Objectのrender関数にところで呼び出します。

const renderComponent = (args: Args) => {
  const { _itemDisabled, _itemAriaLabel, ...rest } = args;
  const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest;
  const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const;
  const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const;
  const rootProps = {
    ...rootRest,
    disabled: _rootDisabled,
    "aria-label": _rootAriaLabel,
  };
  const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel };

  return (
    <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}>
      <ToggleGroupItem value="bold" aria-label="Toggle bold">
        <Bold className="h-4 w-4" />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Toggle italic">
        <Italic className="h-4 w-4" />
      </ToggleGroupItem>
      <ToggleGroupItem value="strikethrough" {...itemProps}>
        <Underline className="h-4 w-4" />
      </ToggleGroupItem>
    </ToggleGroup>
  );
};

export const Outline = {
  render: (args: Args) => renderComponent({ ...args, variant: "outline" }),
};

お約束

アプリプラットフォーム「Yappli」を開発・提供している株式会社ヤプリは、Mobile Tech for Allをミッションに、モバイルテクノロジーで世の中をもっと便利に、もっと楽しくすることを目指しています。デザインシステムの構築や技術的な負債の解消、より価値のあるCX/UXを届ける機能開発など、チャレンジングな課題が山積しています。少しでも興味があれば、お声掛けください。

🎄🎍Happy Hacking 🎍🎄