Back to Portfolio

Case Study · 02 · Live

theme-slot

個人開発のお題を ランダム に決める、「ジャンル × アプリ種類」のテーマスロット

Problem

個人開発は、何を作るか決める段階で止まりがちだった

個人開発でいちばん時間がかかるのは、実装ではなく 「何を作るか」を決める段階 だった。0 から 1 を立ち上げるのが苦手で、テーマが決まらないと手が動かない。決められない こと自体が、毎回いちばんの足止めになっていた。

そこで、自分で考えて決めるのをやめて、ランダムに引く道具 を作った。「ジャンル × アプリ種類」をボタンで一つ出し、出た組み合わせでそのまま作り始める。お題の良し悪しは考えず、まず手を動かすことを優先した。

Approach

スロットの "止まる瞬間" を中心に組む

出力形式を 「hogehoge × fugafuga」(例: サッカー × TODO)の 2 要素に固定し、左 = ジャンル / 右 = アプリ種類のスロットとして組んだ。結果のテキストより、左が止まり、続いて右が止まる という二段階の止まり方そのものを設計の中心に置いている。

スロットのフェーズは useSlot のステートマシン (idle / spinning / left-stopped / done) に閉じ込め、タイマーは useRef で持って useEffect のクリーンアップで確実に解放。アニメーションは DOM を直接触らず、Tailwind ユーティリティと CSS keyframes (animate-slot-blur / animate-bounce-in) だけで表現した。ロジックはフックへ、コンポーネントは presentational に寄せる方針。

状態は最小限に振り切る。履歴と項目カスタマイズは lib/storage.ts 経由の localStorage に集約し、失敗はそこで握りつぶして呼び出し側を try/catch から解放した。デザイントークンは Tailwind v4 の CSS-first 構成に従い、src/index.css@theme ブロックに --color-* / --font-* として定義(tailwind.config.ts は使わない)。

シェアは静的サイトの弱点を埋める一手。結果を Cloudflare Pages Functions + workers-og(satori + resvg-wasm)でその場の動的 OGP 画像にし、X へテーマ名+画像付きで投稿できるようにした。サーバーを持たずにエッジだけで OGP を焼くこの構成が、技術的にいちばん遊んだ部分。

スロット抽選

ボタン or Space / Enter でスロットが回り、左 → 右の順に止まって「ジャンル × アプリ種類」が確定する。キーボード操作だけで完結。

ローカル履歴

引いた組み合わせを localStorage に蓄積。過去のお題を遡れるので、ボツ案の再利用や被りチェックに使える。

項目カスタマイズ

左右の候補リストを自分の好みで編集。テーマの縛りを自分のルールに寄せられる。下書き状態はこの画面だけがローカルに持つ。

動的 OGP シェア

シェアボタンで、抽選結果を焼き込んだ OGP 画像付きの X 投稿を生成。/og?left=…&right=… をエッジで都度レンダリングする。

インストール可能な PWA

Workbox の Service Worker でアプリシェルをキャッシュ。ホーム画面に追加すればオフラインでも起動する。

Build
Vite 8 (tsc -b && vite build)
UI
React 19 + TypeScript 6 (strict, verbatimModuleSyntax)
Styling
Tailwind CSS v4 (CSS-first / @theme トークン)
State
useSlot ステートマシン + localStorage (lib/storage.ts)
PWA
vite-plugin-pwa (Workbox SW / Web App Manifest)
Dynamic OGP
Cloudflare Pages Functions + workers-og (satori + resvg-wasm)
Testing
Vitest 4 + Testing Library (jsdom) / Playwright (Chromium)
Quality
ESLint 10 (flat) / Prettier 3 / pnpm check で一括検証
Deploy
Cloudflare Pages (静的配信 + Functions)
AI 協業
Claude Code (CLAUDE.md / .claude/rules / commands / agents / hooks)

Result

サーバーを持たないまま、シェアまで実装した

theme-slot.c12o.net で公開中。スロット・履歴・カスタマイズ・OGP シェア・PWA までを、バックエンドを持たない静的サイト+エッジ Functions の構成だけで実装した。Cloudflare Pages に載せきっているので運用コストは実質ゼロ。

プロトタイプは 1 枚の HTML (theme-generator.html) から始め、そこから Vite + React + Tailwind v4 の本実装へ作り直した。「動くものを最速で出してから、壊れにくい形に組み直す」という個人開発の定石を、小さなスコープでひと通りなぞれた題材になっている。

Learnings

作って分かったこと

"間" を演出に変えると、単純な乱数が体験になる

左 → 右の二段階で止める表示を入れるだけで、同じ Math.random() でも結果を待つ時間そのものが体験になる。プロダクトの面白さは、ロジックの精度より「結果が出るまでの時間の設計」で決まることがある。

タイマー系の状態はステートマシンに閉じ込めると壊れない

回転 → 左停止 → 右停止のフェーズを useSlot の有限状態に押し込み、タイマーは useRef + クリーンアップで管理。連打やアンマウント時の取りこぼしが、状態を一箇所に集約した時点で消えた。

Tailwind v4 の CSS-first は設定ファイルを 1 枚消す

デザイントークンを tailwind.config.ts ではなく @theme ブロックに置くことで、設定の置き場所が CSS に一本化された。トークンとユーティリティが同じ言語に揃うと、デザインの調整が速い。

静的サイトの "シェアできない問題" はエッジで解ける

SPA は OGP が静的になりがちで、SNS シェアと相性が悪い。Pages Functions + workers-og で結果ごとに画像を焼くことで、サーバーを持たないまま「結果が見えるシェア」を実現できた。

localStorage アクセスは 1 ファイルに寄せると呼び出し側が軽くなる

loadJSON / saveJSON が失敗を握りつぶす lib/storage.ts を通すルールにしたら、履歴・カスタマイズの各フックから try/catch が消えた。永続化の例外処理は、境界の 1 箇所で吸収するのが結局いちばん読みやすい。