initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
snc 2026-07-30 21:22:21 +09:00
commit 6cba1f1351
47 changed files with 13482 additions and 0 deletions

View File

@ -0,0 +1,245 @@
---
name: creating-visual-explainers
description: Generates an illustrated HTML page about any topic and deploys it to surge.sh with a comment widget (text/pin/voice feedback). Triggered by requests like "図解を作って", "図解を生成して", "このトピックを図解して", "図解してデプロイして", "この図解を再デプロイして", or "この図解を削除して".
---
# Creating Visual Explainers
任意のトピックについて、前提知識がなくても理解できる図解HTMLを生成し、コメント機能テキスト引用・ピン・音声付きで surge.sh に公開する。品質基準は「入社したての新卒社会人が読んでも腹落ちする明快さ」だが、この基準は出力には表示しない。
## 依存
- `references/base.html` — 図解テンプレートTailwind CSS CDN・Lucide Icons CDN・ADS配色を含む「額縁」
- `references/model-answer.html` — 模範回答品質基準・デザインパターンの実例。base.htmlと同一の額縁を含む完全なHTMLファイル。widget 未含(デプロイ時に自動注入されるため)
- リポジトリルートの `fb-tool-url.txt` / `fb-api-token.txt` — セットアップスキル(`setting-up-comment-tool`)が生成する
## ワークフロー
### Step 0: 前提確認
1. `references/base.html` が存在するか確認する。存在しない場合、以下を伝えて終了:
> テンプレートファイルが見つかりません。スキルのフォルダ構成が壊れている可能性があります。運営に連絡してください。
2. リポジトリルート(`.claude` フォルダがある階層)に `fb-tool-url.txt` が存在するか確認する。存在しない場合、以下を伝えて終了:
> コメントツールのセットアップがまだ完了していません。
> 先にチャット欄で「セットアップして」と伝えてください。
### Step 1: 模範回答の読み込み
`references/model-answer.html` を読み、以下を把握する:
- 完成品の品質水準
- デザインパターン(色使い・余白・カード・フロー図などの視覚表現)
- Tailwind CSSクラスの使い方
- Lucide Iconsの使い方
- コンテンツの構成・情報量・説明の深さ
模範回答がデザインガイドラインの代わりになる。パーツ一覧やルールではなく、実物から読み取る。
### Step 2: テンプレートの読み込み
`references/base.html` を読み、額縁の構造を把握する:
- `<!-- CONTENT_START -->``<!-- CONTENT_END -->` のプレースホルダー位置
- `<!-- TITLE -->`, `<!-- DESCRIPTION -->` のプレースホルダー
- ADS配色のTailwind設定
- 読み込み済みのCDNTailwind CSS・Lucide Icons
### Step 3: ウェブで情報収集
トピックについてウェブ検索を行い、正確かつ最新の情報を収集する。
検索は **2〜3回** に絞る。以下の観点でクエリを組み立てる:
1. **正確な定義**: トピックの公式な定義、公式ドキュメントの説明
2. **最新動向**: 直近の変更点、アップデート、現在のベストプラクティス
3. **具体例**: 実際の使われ方、初心者に伝わるたとえに使える事例
検索結果から以下を整理し、Step 4 の参考情報とする:
- トピックの正確な定義検索結果を優先。AIの学習データだけに頼らない
- 最近変わった点があれば、それを明記する
- たとえ話に使えそうな事例や数字
- **出典URL**: 図解に採用した情報のソースURLを控えておくStep 4 でインライン出典に使う)
### Step 4: コンテンツ生成
Step 3 で収集した情報をもとに、図解HTMLを生成する。検索結果で得た定義・事実・具体例を優先的に採用する。
模範回答のデザインを参考にしつつ、Tailwindの語彙で自由にレイアウトを組む。模範回答のパターンに合うものはそのまま使い、合わないものはTailwindクラスでその場で作る。テンプレートの定義済みパーツに縛られない。
### Step 5: ファイル作成
1. `output/` ディレクトリがなければ作成する
2. トピックに関連する短い英単語のスラッグを決める(例: `api-basics`, `git-rebase`
3. `references/base.html``output/{スラッグ}.html` にコピーする
4. コピーしたファイル内のプレースホルダーをすべて置換する:
- `<!-- TITLE -->` → 図解のタイトル
- `<!-- DESCRIPTION -->` → 内容を要約した1文
- `<!-- CONTENT_START -->``<!-- CONTENT_END -->` → Step 4で生成したコンテンツ
5. ファイルを保存する(ブラウザで開くのは Step 6 のデプロイ後に行う。ローカルでは開かない)
### Step 6: 公開
ファイル保存後、公開する前にまず Node.js の有無を確認する。
```bash
node --version
```
バージョン番号が表示された → そのまま「公開の実行」に進む。
`command not found` と表示された → `references/node-install-guide.md` の手順に従ってNode.jsのインストールを案内する。
#### 公開の実行
以下のスクリプトを**実行する**(中身を読む必要はない)。コメントウィジェットの埋め込みはこのスクリプトが自動で行う。
**macOS / Git BashWindowsの場合:**
```bash
bash .claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh output/{スラッグ}.html [スラッグ]
```
**WindowsPowerShellで bash が使えない場合:**
```powershell
$fbUrl = (Get-Content fb-tool-url.txt).Trim()
$apiToken = (Get-Content fb-api-token.txt).Trim()
$snippet = "<script src=`"$fbUrl/widget.js`" data-token=`"$apiToken`"></script>"
$dir = "$env:TEMP\diagram-deploy"
New-Item -ItemType Directory -Force $dir | Out-Null
# (?s)^(.*)</body> は貪欲マッチのため「最後の </body>」だけを置換する
# (本文中にコード例として </body> があっても多重注入しない。bash 版と同じ挙動)
(Get-Content output/{スラッグ}.html -Raw) -replace '(?s)^(.*)</body>', ('$1' + $snippet + '</body>') | Set-Content "$dir\index.html"
Set-Content "$dir\robots.txt" "User-agent: *`nDisallow: /"
npx --yes surge $dir --domain diagram-[スラッグ].surge.sh
Add-Content deploy-history.log "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | https://diagram-[スラッグ].surge.sh"
```
スラッグにはトピックに関連する短い英単語を指定する(例: `git-rebase`, `api-basics`)。
#### 初回の場合Surge未登録
ターミナルにメールアドレスとパスワードの入力を求められる。以下を伝える:
> 初回のみアカウント登録が必要です。
> メールアドレスを入力して Enter → パスワードを決めて入力して Enter。
> 確認メールが届いたらリンクをクリックすれば登録完了です。
> 次回以降はこの手順は不要です。
#### エラーが出た場合
エラーメッセージをそのまま見せず、**何が起きていて何をすれば解決するか**を、専門用語を避けて平易に説明する。
よくあるエラーと対応:
- **`npx: command not found`** — Node.js がまだ入っていない。`references/node-install-guide.md` の手順を案内する
- **`surge: not found` / surge関連エラー** — `npm install -g surge` を実行してから再度試す
- **認証エラー / `Login required`**`npx surge login` を実行してメールアドレスとパスワードを入力する
- **`fb-tool-url.txt が見つかりません`** — セットアップが未完了。チャット欄で「セットアップして」と伝えるよう案内する
- **その他** — エラーの内容を読み、「何が問題で」「次に何をすればいいか」を平易に説明する
### 図解の再デプロイ
ユーザーが「この図解を再デプロイして」と依頼した場合(内容の修正後や、公開済み図解にコメント機能を付け直すとき):
1. 対象の HTML を特定する(直近に編集した `output/{スラッグ}.html`。不明ならリポジトリルートの `deploy-history.log` から URL を確認し、ドメインの `diagram-` 以降がスラッグ)
2. 「公開の実行」と同じコマンドを、**同じスラッグ**で再実行する(同じ URL に上書き公開される)
### 図解の削除
ユーザーが「この図解を削除して」と依頼した場合:
1. リポジトリルートの `deploy-history.log` を読み、直近のデプロイURLを特定する
- ログが存在しない場合 → ユーザーに削除したいURLを聞く
2. `npx surge teardown [ドメイン]` を実行する
3. 削除完了をユーザーに伝える
### Step 7: 完了報告
#### 公開に成功した場合
```
完成・公開完了: 【図解のタイトル】
図解の内容を1〜2文で要約
公開URL:
https://diagram-スラッグ.surge.sh
図解の主なポイント:
- 主要トピックを3〜5個
コメントの付け方:
ページ右端のボタンから、テキスト選択・ピン・音声(マイクに向かって喋る)の
3通りでフィードバックできます。
この図解を削除したいとき:
チャット欄で「この図解を削除して」と伝えてください。
```
#### 公開できなかった場合
```
完成: 【図解のタイトル】
図解の内容を1〜2文で要約
ファイルの保存先:
output/{スラッグ}.htmlブラウザにドラッグドロップすると表示できます
図解の主なポイント:
- 主要トピックを3〜5個
URLで共有したいとき:
チャット欄で「この図解を公開して」と伝えてください。
```
## 守ること(禁止事項)
- **見出し(`h1`〜`h4`)を必ず入れる** — 音声コメントはページ内の見出しを手がかりにピンを配置する。見出しが1つも無いHTMLでは音声フィードバックがエラーになる
- **React・shadcn/ui を使わない** — 静的な図解にJSフレームワークは不要。AIの出力を制限し、モデルによる品質差も限定的にしてしまう
- **絵文字を使わない** — OS依存で表示が変わる。アイコンはLucide Iconsを使う
- **インタラクティブ要素を入れない** — トグル、フェードイン、アニメーション、フォーム、クリックで開閉する要素は一切禁止
- **`<style>` タグを追加しない** — スタイリングはTailwind CSSクラスで行う。インラインの `style` 属性も避ける
- **`<script>` を追加しない** — テンプレートに含まれるもの以外のJavaScriptは禁止。widget.js の埋め込みはデプロイスクリプトが自動で行うため、SKILL の範囲外
- **外部リソースを追加しない** — テンプレートに含まれるCDN以外の外部読み込み画像URL・フォント・追加CDNは禁止
- **テンプレートの額縁構造を変更しない**`<head>`・CDN読み込み・meta タグはそのまま維持する
- **PDF印刷で消える表現を使わない**`bg-clip-text text-transparent` によるグラデーション文字はPDF出力時に透明のまま消える。テンプレートの print CSS でフォールバックを入れているが、グラデーション文字を使った場合はPDF出力で色が単色`#2563EB`)に変わる点を意識すること
## コンテンツ生成の指針
- **概論 → 各論** — いきなり詳細に入らない。全体像を見せてから個別の話に入る
- **専門用語は初出で必ず解説** — 「APIApplication Programming Interfaceソフトウェア同士がやり取りするための窓口」のように、括弧書きで平易に説明する
- **たとえ話で身近な体験に結びつける** — レストランの注文、郵便配達、信号機など、技術を知らない人でもイメージできる例を使う
- **簡潔にまとめすぎない** — 理解に必要な情報量は削らない。腹落ちするまで丁寧に説明する
- **図を早く見せる** — 見出しから最初のビジュアルまでに、テキストは最大2段落各2〜3行。それ以上の説明が必要な場合は、図を先に見せてから図の後にテキストで補足する図→説明の順。「説明してから図を見せる」より「図を見せてから説明する」方が、同じ文量でも「読まされている感」がなくなる。テキストで述べたたとえ話ゲーム、レストラン等も、テキストだけで済まさずミニビジュアル化を検討する
- **「見たことがあるもの」は説明するのではなく見せる** — 図解の中で読者がすでに体験しているものアプリの画面、ツールのUI、Webサイトに言及するとき、テキストで「〜という画面が表示されます」と書く代わりに、その画面自体をTailwind CSSで再現して配置する。「天気アプリの画面」なら天気アプリのモックアップを、「チャット画面」ならチャットUIを、「ターミナル」ならターミナルウィンドウを見せる。読者の「見たことある」という記憶が発火する瞬間が、テキスト説明より圧倒的に理解を深める
- **ビジュアルには2つの役割がある** — 図解に使うビジュアルは「構造を示す図」と「体験を再現する図」の2種類。どちらか一方ではなく、両方を組み合わせて使う:
- **構造パターン(仕組みを頭で理解する図)** — 知識の種類に応じて選ぶ:
- **「Xとは何か」(定義)** → アナロジー図: 身近なたとえの登場人物を配置し、矢印で関係を示す。主役を中央に大きく
- **「Xはどう動くか」(プロセス)** → ステップフロー: 番号つき横並び(モバイルは縦)。各ステップにアイコン+一言。色はステップごとに変える
- **「XとYの違い」(比較)** → 左右対比: 2カラムで並べ、同じ観点を同じ行に揃える。✗/✓ や赤/緑で差を一目で伝える
- **「Xの具体例」(事例)** → カードグリッド: 2列のカードにアイコンタイトル説明。カード内で「表の顔」と「裏の仕組み」を分けると深みが出る
- **「Xのすごさ」(数値)** → 数字カード: 3カラムに大きな数字。text-3xl font-black で存在感を出し、色を各数字で変える
- **「Xの構造・中身」(階層)** → 入れ子ブロック: 外側の大きなブロック内に構成要素を配置。ボーダーや背景色の濃淡で階層を表現
- **「Xの誤解」(訂正)** → 誤解→正解カード: 赤ヘッダーに誤解、本文に緑で正解。✗と✓の対比
- **「Xの変遷」(時系列)** → タイムライン: 左にボーダーライン、各時点に年号・ラベル・要約。色のグラデーションで時代の変化を表現
- **体験再現パターン(読者の「見たことある」記憶と結びつける図)** — 読者が実際に触れたことのある画面をTailwind CSSで再現する。「あなたが見ているもの」「〜の画面では」「実際に使うとき」のような文脈が出てきたら、テキスト説明ではなくこちらを使う:
- **チャットUI** — 吹き出し形式の対話画面。暗い背景ユーザー吹き出し右寄せAI吹き出し左寄せ入力欄モック
- **エディタUI** — タイトルバー赤黄緑ボタンサイドバーファイルツリーコードペイン。AIチャットパネルを加えた3ペインも可
- **ターミナルUI** — タイトルバー(赤黄緑ボタン)+プロンプト($)+コマンド+出力。コードブロックとの違いはウィンドウ枠がある点
- **ブラウザUI** — タイトルバー(赤黄緑ボタン)+アドレスバー+ページ内容
- **アプリ画面** — 上記に当てはまらないアプリ(天気予報、地図、決済画面など)。角丸カード内にアイコン+数値+ラベルでミニ画面を再現
- 共通ルール: 中身はリアルすぎず要点が伝わるミニマルな内容にする。特定ブランドのロゴや名称は使わず汎用的な表現にする。Tailwind CSSクラスのみで構成する
- **冒頭にヒーロー+一枚絵サマリー** — タイトルで「何の図解か」を伝えたあと、そのままトピックの核心を1枚の図で見せる。ヒーローの説明文を図への導入「ひとことで言えば──」のような引きにし、図の直後に各論への橋渡し「ここからひとつずつ解説していきます」を置く。ヒーロー → 一言の答え → 図 → 各論が一本の流れになること。一枚絵サマリーの構成:
- **一言の答え**: トピックの核心を1文で太字表示カード冒頭。読者が「なるほど」と思える定義を先に読ませ、そのあとの図で視覚的に裏付ける
- **コア図解**: 一言の答えを図にしたもの。アイコン+矢印+ラベルで表現
- **身近な接点**: 読者がすでに体験している具体例を、アイコン一言のピルで2〜4個並べる
- **読者のレベルに言及しない** — 「初心者向け」「入門」「未経験者でもわかる」のように読者を特定のレベルにラベリングする表現をタイトル・見出し・本文に入れない。「わかりやすく説明する」のは図解の品質であって、読者のレベルではない。たとえ話や専門用語の解説で自然に噛み砕けば、ラベルなしで誰にでも伝わる
- **ヒーローバッジはトピックカテゴリ** — タイトル上のバッジには、トピックのカテゴリを入れる(テクノロジー、ビジネス、開発ツール、デザイン、マーケティングなど)。「初心者向け」「入門」など読者のレベルを示すラベルは入れない
- **インライン出典** — 検索結果から採用した事実・定義・数字のすぐ近くに、出典リンクをさりげなく添える。本文の読みやすさを損なわないよう `text-xs text-ads-dim` で小さく薄く表示し、リンクテキストはURLそのままではなく「出典: ○○公式ドキュメント」のようにページ内容がわかる名前にする。段落末やカード下部など、視線の流れを邪魔しない位置に置く
- **日本語で** — 英語メインのトピックでも、図解は日本語で書く

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<meta name="googlebot" content="noindex, nofollow">
<meta property="og:title" content="<!-- TITLE -->">
<meta property="og:description" content="<!-- DESCRIPTION -->">
<meta property="og:type" content="article">
<title><!-- TITLE --></title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFFFFF'/><rect x='8' y='8' width='84' height='84' rx='14' fill='none' stroke='%233B82F6' stroke-width='4'/><circle cx='32' cy='40' r='10' fill='%233B82F6' opacity='0.8'/><circle cx='68' cy='40' r='10' fill='%2360A5FA' opacity='0.8'/><rect x='25' y='62' width='50' height='6' rx='3' fill='%2394A3B8' opacity='0.6'/><rect x='30' y='74' width='40' height='6' rx='3' fill='%2394A3B8' opacity='0.3'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
ads: {
bg: '#FFFFFF',
surface: '#F8FAFC',
hover: '#F1F5F9',
border: '#E2E8F0',
accent: '#3B82F6',
'accent-light': '#2563EB',
text: '#1E293B',
muted: '#64748B',
dim: '#94A3B8',
positive: '#10B981',
negative: '#EF4444',
warning: '#F59E0B',
}
},
fontFamily: {
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
}
}
}
}
</script>
<style>
@media print {
.no-print { display: none !important; }
body { border-top: none !important; }
.rounded-xl { break-inside: avoid; }
.md\:flex-row { flex-direction: row !important; }
.md\:hidden { display: none !important; }
.hidden.md\:block { display: block !important; }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
.md\:mb-20 { margin-bottom: 5rem !important; }
.md\:py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
.bg-clip-text.text-transparent {
-webkit-background-clip: initial !important;
background-clip: initial !important;
color: #2563EB !important;
-webkit-text-fill-color: #2563EB !important;
}
}
</style>
</head>
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
<div class="no-print max-w-3xl mx-auto px-5 pt-2 flex justify-end">
<button onclick="window.print()" class="flex items-center gap-1.5 text-xs text-ads-dim hover:text-ads-accent transition-colors cursor-pointer">
<i data-lucide="download" class="w-3.5 h-3.5"></i>
PDF
</button>
</div>
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
<!-- CONTENT_START -->
<!-- CONTENT_END -->
</main>
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
<p class="text-xs text-ads-dim text-center">AI-Driven School の図解ツールで作成</p>
</footer>
<script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script>
</body>
</html>

View File

@ -0,0 +1,968 @@
<!-- このファイルの<head>はreferences/base.htmlと同一に保つ。base.html変更時は必ずこちらも更新する -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<meta name="googlebot" content="noindex, nofollow">
<meta property="og:title" content="APIの仕組み">
<meta property="og:description" content="APIの仕組みを、身近な例とビジュアルでわかりやすく図解します。">
<meta property="og:type" content="article">
<title>APIの仕組み</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFFFFF'/><rect x='8' y='8' width='84' height='84' rx='14' fill='none' stroke='%233B82F6' stroke-width='4'/><circle cx='32' cy='40' r='10' fill='%233B82F6' opacity='0.8'/><circle cx='68' cy='40' r='10' fill='%2360A5FA' opacity='0.8'/><rect x='25' y='62' width='50' height='6' rx='3' fill='%2394A3B8' opacity='0.6'/><rect x='30' y='74' width='40' height='6' rx='3' fill='%2394A3B8' opacity='0.3'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
ads: {
bg: '#FFFFFF',
surface: '#F8FAFC',
hover: '#F1F5F9',
border: '#E2E8F0',
accent: '#3B82F6',
'accent-light': '#2563EB',
text: '#1E293B',
muted: '#64748B',
dim: '#94A3B8',
positive: '#10B981',
negative: '#EF4444',
warning: '#F59E0B',
}
},
fontFamily: {
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
}
}
}
}
</script>
<style>
@media print {
.no-print { display: none !important; }
body { border-top: none !important; }
.rounded-xl { break-inside: avoid; }
.md\:flex-row { flex-direction: row !important; }
.md\:hidden { display: none !important; }
.hidden.md\:block { display: block !important; }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
.md\:mb-20 { margin-bottom: 5rem !important; }
.md\:py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
.bg-clip-text.text-transparent {
-webkit-background-clip: initial !important;
background-clip: initial !important;
color: #2563EB !important;
-webkit-text-fill-color: #2563EB !important;
}
}
</style>
</head>
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
<div class="no-print max-w-3xl mx-auto px-5 pt-2 flex justify-end">
<button onclick="window.print()" class="flex items-center gap-1.5 text-xs text-ads-dim hover:text-ads-accent transition-colors cursor-pointer">
<i data-lucide="download" class="w-3.5 h-3.5"></i>
PDF
</button>
</div>
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
<!-- CONTENT_START -->
<!-- ============================================================ -->
<!-- HERO -->
<!-- ============================================================ -->
<div class="text-center mb-8 md:mb-10">
<div class="inline-flex items-center gap-2 bg-ads-accent/10 text-ads-accent-light px-4 py-1.5 rounded-full text-sm font-medium mb-6">
<i data-lucide="cpu" class="w-4 h-4"></i>
テクノロジー
</div>
<h1 class="text-3xl md:text-5xl font-black text-slate-900 tracking-tight mb-6">
<span class="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">API</span>の仕組み
</h1>
<p class="text-lg text-ads-muted max-w-xl mx-auto leading-relaxed">
「APIって何」と聞かれて、うまく答えられない。<br>
ひとことで言うと ──
</p>
</div>
<!-- ============================================================ -->
<!-- ONE-PAGE SUMMARY -->
<!-- ============================================================ -->
<div class="bg-ads-surface border border-ads-border rounded-2xl p-6 md:p-10 mb-6">
<div class="text-center mb-8 md:mb-10">
<p class="text-xl md:text-2xl font-black text-slate-900 mb-2">
API = ソフトウェアの<span class="text-ads-accent-light">「注文窓口」</span>
</p>
<p class="text-sm text-ads-muted">
中身を知らなくても、決まった形で頼めば結果が届く
</p>
</div>
<!-- Core flow: App → API → Server -->
<div class="flex flex-col md:flex-row items-center justify-center gap-2 md:gap-0 mb-8">
<!-- App -->
<div class="flex flex-col items-center w-36 p-3">
<div class="w-14 h-14 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-2.5">
<i data-lucide="smartphone" class="w-7 h-7 text-blue-600"></i>
</div>
<div class="font-bold text-slate-900 text-sm">あなたのアプリ</div>
<div class="text-[11px] text-blue-600/70 mt-0.5">「天気を教えて」</div>
</div>
<!-- Arrow 1: Request -->
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[9px] font-medium text-ads-accent tracking-wider">リクエスト</span>
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
</div>
</div>
<!-- API (centerpiece — visually prominent) -->
<div class="flex flex-col items-center w-40 p-4 bg-ads-accent/5 border-2 border-ads-accent/20 rounded-2xl">
<div class="w-16 h-16 rounded-2xl bg-ads-accent/15 border-2 border-ads-accent/30 flex items-center justify-center mb-2.5">
<i data-lucide="arrow-left-right" class="w-8 h-8 text-ads-accent-light"></i>
</div>
<div class="font-bold text-ads-accent-light text-base">API</div>
<div class="text-[11px] text-ads-muted mt-0.5 mb-2">注文を届け、結果を返す</div>
<div class="text-[10px] text-ads-muted bg-white rounded-lg px-2.5 py-1 border border-ads-border/50">レストランのウェイター役</div>
</div>
<!-- Arrow 2: Forward to server -->
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[9px] font-medium text-ads-accent tracking-wider">依頼</span>
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
</div>
</div>
<!-- Server -->
<div class="flex flex-col items-center w-36 p-3">
<div class="w-14 h-14 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-2.5">
<i data-lucide="server" class="w-7 h-7 text-emerald-600"></i>
</div>
<div class="font-bold text-slate-900 text-sm">サービス</div>
<div class="text-[11px] text-emerald-600/70 mt-0.5">処理して結果を返す</div>
</div>
</div>
<!-- Return flow -->
<div class="flex justify-center mb-8">
<div class="flex items-center gap-2 text-xs text-emerald-600 bg-emerald-500/5 border border-emerald-500/15 rounded-full px-4 py-1.5">
<i data-lucide="corner-down-left" class="w-3.5 h-3.5"></i>
結果(レスポンス)があなたのアプリに届く
</div>
</div>
<!-- Divider with label -->
<div class="flex items-center gap-4 mb-6">
<div class="flex-1 border-t border-ads-border/50"></div>
<div class="text-[11px] text-ads-dim font-medium">あなたも毎日使っている</div>
<div class="flex-1 border-t border-ads-border/50"></div>
</div>
<!-- Everyday examples as pills -->
<div class="flex flex-wrap justify-center gap-2 mb-8">
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
<i data-lucide="cloud" class="w-3.5 h-3.5 text-cyan-500"></i>
天気予報
</div>
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
<i data-lucide="log-in" class="w-3.5 h-3.5 text-blue-500"></i>
Googleログイン
</div>
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-emerald-500"></i>
オンライン決済
</div>
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
<i data-lucide="map-pin" class="w-3.5 h-3.5 text-red-500"></i>
地図・ナビ
</div>
</div>
</div>
<p class="text-center text-ads-muted mb-16 md:mb-20">
ここから先で、この仕組みをひとつずつ丁寧に解説していきます。
</p>
<!-- ============================================================ -->
<!-- SECTION 1: そもそもAPIって何 -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 flex-shrink-0">
<i data-lucide="help-circle" class="w-5 h-5 text-blue-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">そもそもAPIって何</h2>
</div>
<p class="mb-6 leading-relaxed">
APIエーピーアイ<strong class="text-slate-900">Application Programming Interface</strong>(アプリケーション・プログラミング・インターフェース)の略称です。正式名称を聞いても「何のこと?」と思いますよね。
</p>
<p class="mb-6 leading-relaxed">
まずは日常のたとえで考えてみましょう。レストランに行った場面を想像してください。
</p>
<p class="mb-8 leading-relaxed">
あなた(お客さん)は、厨房に直接入って料理を作ることはできません。厨房のルールも、調理器具の使い方も知りません。でも、<strong class="text-slate-900">ウェイターに「パスタをください」と注文すれば、厨房で作られた料理があなたのテーブルに届きます。</strong>厨房の中で何が起きているかを知る必要はありません。
</p>
<!-- レストラン比喩フロー図 -->
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">レストランで考えるAPIの役割</h3>
<div class="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-0">
<!-- あなた(お客さん) -->
<div class="w-44 bg-blue-500/10 border border-blue-500/20 rounded-xl p-5 text-center">
<div class="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center mx-auto mb-3">
<i data-lucide="user" class="w-6 h-6 text-blue-600"></i>
</div>
<div class="font-bold text-blue-700 mb-1">あなた</div>
<div class="text-xs text-blue-600/70">お客さん</div>
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">「パスタください」</div>
</div>
<!-- 矢印 1 -->
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] text-ads-muted">注文</span>
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
</div>
</div>
<!-- APIウェイター -->
<div class="w-44 bg-ads-accent/10 border-2 border-ads-accent/30 rounded-xl p-5 text-center">
<div class="w-12 h-12 rounded-full bg-ads-accent/20 flex items-center justify-center mx-auto mb-3">
<i data-lucide="message-square" class="w-6 h-6 text-ads-accent-light"></i>
</div>
<div class="font-bold text-ads-accent-light mb-1">API</div>
<div class="text-xs text-ads-accent/70">ウェイター</div>
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">注文を伝え、料理を届ける</div>
</div>
<!-- 矢印 2 -->
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] text-ads-muted">依頼</span>
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
</div>
</div>
<!-- サーバー(厨房) -->
<div class="w-44 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-5 text-center">
<div class="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mx-auto mb-3">
<i data-lucide="server" class="w-6 h-6 text-emerald-600"></i>
</div>
<div class="font-bold text-emerald-700 mb-1">サーバー</div>
<div class="text-xs text-emerald-600/70">厨房</div>
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">パスタを作って渡す</div>
</div>
</div>
<div class="flex justify-center mt-6">
<div class="flex items-center gap-2 text-ads-muted text-sm">
<i data-lucide="corner-down-left" class="w-4 h-4"></i>
料理(レスポンス)があなたのテーブルに届く
</div>
</div>
</div>
<p class="mb-6 leading-relaxed">
この比喩がAPIの本質をほぼ言い当てています。あなたアプリは、厨房サーバーの中で何が起きているかを知る必要がありません。ウェイターAPIに決まった形式で注文を伝えれば、結果が返ってくる。これがAPIです。
</p>
<!-- ポイントボックス -->
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
</div>
<div>
<p class="font-bold text-ads-accent-light mb-1">ここがポイント</p>
<p class="text-ads-muted leading-relaxed">
APIは<strong class="text-slate-800">「仲介役」</strong>です。相手の内部構造を知らなくても、<strong class="text-slate-800">決まったルールで話しかければ結果が返ってくる</strong>。これがAPIの本質です。この「決まったルール」のことを、エンジニアは「インターフェースInterface」と呼びます。
</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2: もう少し正確に言うと -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-purple-500/10 flex-shrink-0">
<i data-lucide="search" class="w-5 h-5 text-purple-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">もう少し正確に言うと</h2>
</div>
<p class="mb-6 leading-relaxed">
レストランのたとえで、ざっくりとしたイメージはつかめましたか? ここからもう少しだけ正確に説明します。
</p>
<p class="mb-8 leading-relaxed">
APIとは、ひとことで言えば<strong class="text-slate-900">「ソフトウェア同士が会話するための窓口」</strong>です。あなたが使っているアプリの裏側で、別のサービスのデータや機能を借りてくるための「取り決め」と考えてください。
</p>
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 mb-8 text-center">
<div class="text-xs text-ads-dim font-medium tracking-widest uppercase mb-3">技術的な定義</div>
<p class="text-lg md:text-xl font-bold text-slate-900 leading-relaxed">
API = あるソフトウェアの機能を、<br class="hidden md:block">
別のソフトウェアから使えるようにする仕組み
</p>
<p class="text-xs text-ads-dim mt-3">出典: <a href="https://developer.mozilla.org/ja/docs/Learn/JavaScript/Client-side_web_APIs/Introduction" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">MDN Web Docs — Web API の紹介</a></p>
</div>
<p class="mb-8 leading-relaxed">
これだけだとまだ抽象的に感じるかもしれません。では、<strong class="text-slate-900">APIがある世界とない世界</strong>を比べてみましょう。
</p>
<!-- Before / After 比較 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Before -->
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-6">
<div class="inline-flex items-center gap-1.5 bg-red-500/10 text-red-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
<i data-lucide="x-circle" class="w-3.5 h-3.5"></i>
BEFORE — APIがない世界
</div>
<ul class="space-y-3">
<li class="flex items-start gap-2.5">
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
<span>天気情報が欲しければ、<strong class="text-red-700">自分で気象観測の仕組み</strong>を構築する</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
<span>決済機能が欲しければ、<strong class="text-red-700">クレジットカード処理を自前で開発</strong>する</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
<span>地図を表示したければ、<strong class="text-red-700">地図データを自分で作成・更新</strong>する</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
<span>ユーザー認証は<strong class="text-red-700">パスワード管理からセキュリティ対策まで全部自前</strong></span>
</li>
</ul>
<div class="mt-5 pt-4 border-t border-red-500/10 text-sm text-red-700/70 flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
膨大な開発コストと時間。バグのリスクも高い。
</div>
</div>
<!-- After -->
<div class="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-6">
<div class="inline-flex items-center gap-1.5 bg-emerald-500/10 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
AFTER — APIがある世界
</div>
<ul class="space-y-3">
<li class="flex items-start gap-2.5">
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
<span>天気情報は<strong class="text-emerald-700">天気予報APIに問い合わせるだけ</strong>で取得できる</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
<span>決済は<strong class="text-emerald-700">Stripe APIに任せれば数行のコード</strong>で完成</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
<span>地図は<strong class="text-emerald-700">Google Maps APIで高品質な地図を即表示</strong>できる</span>
</li>
<li class="flex items-start gap-2.5">
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
<span>ログインは<strong class="text-emerald-700">GoogleやAppleのAPIで安全に認証</strong>できる</span>
</li>
</ul>
<div class="mt-5 pt-4 border-t border-emerald-500/10 text-sm text-emerald-700/70 flex items-center gap-2">
<i data-lucide="zap" class="w-4 h-4"></i>
「自分が本当に作るべきもの」に集中できる。
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 3: APIの仕組み -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-cyan-500/10 flex-shrink-0">
<i data-lucide="settings" class="w-5 h-5 text-cyan-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIの仕組み — リクエストとレスポンス</h2>
</div>
<p class="mb-6 leading-relaxed">
APIでのやり取りは、実はとてもシンプルです。基本は<strong class="text-slate-900">「聞く(リクエスト)」</strong><strong class="text-slate-900">「答える(レスポンス)」</strong>の2つだけ。
</p>
<p class="mb-8 leading-relaxed">
リクエストRequestとは、「こういう情報をください」「この処理をしてください」とAPIに送るメッセージのことです。レスポンスResponseは、APIがその要求に対して返す結果です。この2つのやり取りを分解すると、4つのステップになります。
</p>
<!-- 4ステップフロー図 -->
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">APIリクエスト〜レスポンスの流れ</h3>
<div class="flex flex-col md:flex-row items-stretch justify-center gap-3 md:gap-0">
<div class="flex-1 bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">1</div>
<i data-lucide="send" class="w-6 h-6 text-blue-600 mx-auto mb-2"></i>
<div class="font-bold text-blue-700 text-sm mb-1">リクエスト送信</div>
<div class="text-xs text-ads-muted leading-relaxed">あなたのアプリが<br>「こういう情報ください」<br>とAPIに送る</div>
</div>
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
</div>
<div class="flex-1 bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
<div class="w-8 h-8 rounded-full bg-purple-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">2</div>
<i data-lucide="shield" class="w-6 h-6 text-purple-600 mx-auto mb-2"></i>
<div class="font-bold text-purple-700 text-sm mb-1">APIが受け取る</div>
<div class="text-xs text-ads-muted leading-relaxed">リクエストの内容を<br>チェック・認証する<br>(門番の役割)</div>
</div>
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
</div>
<div class="flex-1 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4 text-center">
<div class="w-8 h-8 rounded-full bg-emerald-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">3</div>
<i data-lucide="database" class="w-6 h-6 text-emerald-600 mx-auto mb-2"></i>
<div class="font-bold text-emerald-700 text-sm mb-1">サーバーが処理</div>
<div class="text-xs text-ads-muted leading-relaxed">データベース検索や<br>計算など、実際の<br>処理を実行する</div>
</div>
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
</div>
<div class="flex-1 bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-center">
<div class="w-8 h-8 rounded-full bg-amber-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">4</div>
<i data-lucide="reply" class="w-6 h-6 text-amber-600 mx-auto mb-2"></i>
<div class="font-bold text-amber-700 text-sm mb-1">レスポンス返却</div>
<div class="text-xs text-ads-muted leading-relaxed">処理結果をあなたの<br>アプリに返す<br>(料理が届く瞬間)</div>
</div>
</div>
</div>
<p class="mb-6 leading-relaxed">
言葉だけだとまだピンとこないかもしれません。では、実際のコードで見てみましょう。たとえば、天気予報APIから東京の天気を取得するコードは、たったこれだけです。
</p>
<!-- コード例: JavaScript -->
<div class="mb-6">
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700/50 rounded-t-xl px-4 py-2.5 text-xs text-ads-muted">
<i data-lucide="code" class="w-3.5 h-3.5"></i>
JavaScript — 天気予報APIの呼び出し例
</div>
<pre class="bg-slate-950 border border-slate-700/50 border-t-0 rounded-b-xl p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-slate-500">// 1. APIにリクエストを送る「東京の天気を教えて」と聞く</span>
<span class="text-purple-400">const</span> response <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> <span class="text-blue-400">fetch</span>(<span class="text-emerald-400">"https://api.weather.example.com/current?city=tokyo"</span>);
<span class="text-slate-500">// 2. レスポンスをJSON形式データの構造に変換する</span>
<span class="text-purple-400">const</span> data <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> response.<span class="text-blue-400">json</span>();
<span class="text-slate-500">// 3. 必要なデータを取り出して使う</span>
console.<span class="text-blue-400">log</span>(data.temperature); <span class="text-slate-500">// → "22°C"</span>
console.<span class="text-blue-400">log</span>(data.condition); <span class="text-slate-500">// → "晴れ"</span>
console.<span class="text-blue-400">log</span>(data.humidity); <span class="text-slate-500">// → "65%"</span></code></pre>
</div>
<!-- コード解説 -->
<div class="bg-ads-surface border border-ads-border rounded-xl p-5 mb-8">
<h4 class="text-sm font-bold text-slate-900 mb-4 flex items-center gap-2">
<i data-lucide="file-text" class="w-4 h-4 text-ads-accent"></i>
コードの解説1行ずつ読み解く
</h4>
<div class="space-y-4 text-sm">
<div class="flex items-start gap-3">
<span class="text-xs font-mono bg-blue-500/10 text-blue-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">1</span>
<span class="leading-relaxed"><code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">fetch()</code> は「指定したURLに問い合わせる」命令。URLの末尾にある <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">?city=tokyo</code> が「東京の情報が欲しい」というリクエストの中身です。レストランのたとえで言えば「パスタください」にあたる部分。</span>
</div>
<div class="flex items-start gap-3">
<span class="text-xs font-mono bg-purple-500/10 text-purple-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">2</span>
<span class="leading-relaxed">返ってきたデータは機械向けの生データなので、<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">.json()</code> で人間が読みやすい形JSON = JavaScript Object Notationに変換します。JSONは「名前: 値」の組み合わせでデータを表現する書式で、Web業界で最も広く使われています。</span>
</div>
<div class="flex items-start gap-3">
<span class="text-xs font-mono bg-emerald-500/10 text-emerald-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">3</span>
<span class="leading-relaxed">変換したデータから <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">data.temperature</code> のように、ドット(.)で区切って欲しい情報を名前で取り出します。辞書で単語を引くのに似ています。</span>
</div>
</div>
</div>
<p class="mb-4 leading-relaxed">
ターミナルコマンドを入力する黒い画面からもAPIを試せます。<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">curl</code>カールというコマンドを使うと、たった1行でAPIにリクエストを送れます。
</p>
<!-- ターミナルUIモックアップ: curl -->
<div class="mb-8 rounded-xl overflow-hidden border border-slate-700/50">
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
<div class="flex gap-1.5">
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
</div>
<div class="flex items-center gap-1.5 text-xs text-slate-400">
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
ターミナル — curlコマンドでAPIを叩く
</div>
</div>
<pre class="bg-slate-950 p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-emerald-400">$</span> curl https://api.weather.example.com/current?city=tokyo
<span class="text-slate-500"># 返ってくるレスポンスJSON形式</span>
{
<span class="text-blue-400">"city"</span>: <span class="text-emerald-400">"東京"</span>,
<span class="text-blue-400">"temperature"</span>: <span class="text-amber-400">"22°C"</span>,
<span class="text-blue-400">"condition"</span>: <span class="text-emerald-400">"晴れ"</span>,
<span class="text-blue-400">"humidity"</span>: <span class="text-amber-400">"65%"</span>
}</code></pre>
</div>
<div class="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-500/10 flex-shrink-0 mt-0.5">
<i data-lucide="info" class="w-4 h-4 text-amber-600"></i>
</div>
<div>
<p class="font-bold text-amber-700 mb-1">ちょっと補足: URLの構造</p>
<p class="text-ads-muted leading-relaxed text-sm">
<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">https://api.weather.example.com/current?city=tokyo</code> のURLは、大きく3つの部分に分かれます。<strong class="text-slate-800">api.weather.example.com</strong> がAPIの住所ベースURL<strong class="text-slate-800">/current</strong> が「何を」(現在の天気)、<strong class="text-slate-800">?city=tokyo</strong> が「どこの」(東京)というパラメータです。レストランで例えると「〇〇レストランの(住所)、メインメニューから(何を)、パスタを(詳細)」に対応します。
</p>
</div>
</div>
</div>
<p class="mt-8 mb-6 leading-relaxed">
では、このAPIのレスポンスが実際のアプリではどう表示されるのでしょうか あなたが見ている天気アプリの画面を覗いてみましょう。
</p>
<!-- ブラウザUIモックアップ -->
<div class="rounded-xl overflow-hidden border border-slate-700/50 mb-8">
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
<div class="flex gap-1.5">
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
</div>
<div class="flex-1 bg-slate-700/50 rounded-lg px-3 py-1 text-xs text-slate-400 flex items-center gap-1.5">
<i data-lucide="lock" class="w-3 h-3 text-emerald-400"></i>
weather-app.example.com
</div>
</div>
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-6 md:p-8">
<div class="text-center">
<div class="text-xs text-sky-600/70 font-medium mb-1">現在地: 東京</div>
<div class="flex items-center justify-center gap-2 mb-2">
<i data-lucide="sun" class="w-10 h-10 text-amber-500"></i>
<span class="text-4xl font-black text-sky-900">22°C</span>
</div>
<div class="text-sm text-sky-700 font-medium mb-4">晴れ</div>
<div class="flex justify-center gap-6 text-xs text-sky-600/80">
<div class="flex items-center gap-1"><i data-lucide="droplets" class="w-3.5 h-3.5"></i> 65%</div>
<div class="flex items-center gap-1"><i data-lucide="wind" class="w-3.5 h-3.5"></i> 3m/s</div>
</div>
</div>
</div>
</div>
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
</div>
<div>
<p class="font-bold text-ads-accent-light mb-1">APIの結果 → アプリの画面</p>
<p class="text-ads-muted leading-relaxed">
上の天気アプリは、裏側で <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">temperature: "22°C"</code><code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">condition: "晴れ"</code> というAPIレスポンスを受け取り、見やすいデザインに変換して表示しています。<strong class="text-slate-800">あなたが普段見ているきれいな画面の裏側では、こうしたAPIのやり取りが行われている</strong>のです。
</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 4: 身近なAPIの例 -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-amber-500/10 flex-shrink-0">
<i data-lucide="smartphone" class="w-5 h-5 text-amber-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">身近なAPIの例 — 実はあなたも毎日使っている</h2>
</div>
<p class="mb-8 leading-relaxed">
「API」と聞くとプログラマーの専門用語に聞こえるかもしれません。しかし、あなたがスマホで何気なくやっている日常の操作の裏側では、たくさんのAPIが動いています。<strong class="text-slate-900">「あなたが見ている画面」の裏側で、APIが何をしているのか</strong>を図解します。
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 天気予報アプリ -->
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-5">
<div class="text-center">
<div class="text-[10px] text-sky-600/60 font-medium mb-0.5">東京</div>
<div class="flex items-center justify-center gap-1.5 mb-1">
<i data-lucide="sun" class="w-7 h-7 text-amber-500"></i>
<span class="text-2xl font-black text-sky-900">22°C</span>
</div>
<div class="text-xs text-sky-700 font-medium mb-2">晴れ</div>
<div class="flex justify-center gap-4 text-[10px] text-sky-600/70">
<div class="flex items-center gap-0.5"><i data-lucide="droplets" class="w-3 h-3"></i> 65%</div>
<div class="flex items-center gap-0.5"><i data-lucide="wind" class="w-3 h-3"></i> 3m/s</div>
</div>
</div>
</div>
<div class="p-5 pt-4">
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
<i data-lucide="cloud" class="w-4 h-4 text-cyan-600"></i> 天気予報アプリ
</h3>
<div class="text-xs text-cyan-600 font-medium mb-1">裏側でAPIがやっていること</div>
<p class="text-sm text-ads-muted leading-relaxed">気象庁のサーバーに「東京の最新天気データをください」とリクエストを送り、気温・天候・湿度・風速などのデータをJSON形式で受け取っている</p>
</div>
</div>
<!-- Googleログイン -->
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="bg-white p-5 flex flex-col items-center justify-center">
<div class="text-xs text-ads-muted mb-3">アカウントにログイン</div>
<div class="flex items-center gap-2.5 border border-ads-border rounded-lg px-5 py-2.5 bg-white hover:bg-ads-hover transition-colors">
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-blue-500 via-red-500 to-yellow-500 flex items-center justify-center">
<span class="text-[8px] font-black text-white">G</span>
</div>
<span class="text-sm font-medium text-slate-700">Google でログイン</span>
</div>
<div class="flex items-center gap-3 mt-3">
<div class="flex-1 border-t border-ads-border/50"></div>
<span class="text-[10px] text-ads-dim">または</span>
<div class="flex-1 border-t border-ads-border/50"></div>
</div>
<div class="w-full mt-2 bg-ads-surface border border-ads-border/50 rounded-lg px-3 py-1.5 text-xs text-ads-dim">メールアドレスで登録</div>
</div>
<div class="p-5 pt-4">
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
<i data-lucide="log-in" class="w-4 h-4 text-blue-600"></i> 「Googleでログイン」ボタン
</h3>
<div class="text-xs text-blue-600 font-medium mb-1">裏側でAPIがやっていること</div>
<p class="text-sm text-ads-muted leading-relaxed">GoogleのOAuth APIオーオース = 認可の仕組み)に「このユーザーの身元を確認してください」と問い合わせ、認証トークン(本人確認済みの証)を受け取っている</p>
</div>
</div>
<!-- オンライン決済 -->
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="bg-gradient-to-b from-emerald-50 to-white p-5 text-center">
<div class="w-10 h-10 rounded-full bg-emerald-500/15 flex items-center justify-center mx-auto mb-2">
<i data-lucide="check" class="w-6 h-6 text-emerald-600"></i>
</div>
<div class="text-sm font-bold text-emerald-700 mb-1">お支払い完了</div>
<div class="text-xl font-black text-slate-900 mb-1">&#165;1,980</div>
<div class="text-[10px] text-ads-dim">VISA **** 4242</div>
</div>
<div class="p-5 pt-4">
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
<i data-lucide="credit-card" class="w-4 h-4 text-emerald-600"></i> オンライン決済
</h3>
<div class="text-xs text-emerald-600 font-medium mb-1">裏側でAPIがやっていること</div>
<p class="text-sm text-ads-muted leading-relaxed">Stripe等の決済APIが、クレジットカード会社のサーバーと暗号化通信を行い、与信確認この人は支払える→ 決済処理 → 結果通知を実行している</p>
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://docs.stripe.com/api" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">Stripe API 公式ドキュメント</a></p>
</div>
</div>
<!-- 地図・ナビ -->
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="bg-emerald-50/50 p-5">
<div class="relative bg-emerald-100/80 rounded-lg p-4 h-28 flex flex-col justify-between">
<div class="flex items-center gap-1.5">
<div class="w-4 h-4 rounded-full bg-blue-500 border-2 border-white flex items-center justify-center">
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
</div>
<span class="text-[10px] text-blue-700 font-medium">現在地</span>
</div>
<div class="border-l-2 border-dashed border-blue-400/60 ml-2 h-6"></div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<i data-lucide="map-pin" class="w-4 h-4 text-red-500"></i>
<span class="text-[10px] text-red-700 font-medium">東京駅</span>
</div>
<div class="bg-white rounded-full px-2 py-0.5 text-[10px] font-bold text-blue-700 border border-blue-200">12分</div>
</div>
</div>
</div>
<div class="p-5 pt-4">
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
<i data-lucide="map-pin" class="w-4 h-4 text-red-600"></i> 地図・ナビアプリ
</h3>
<div class="text-xs text-red-600 font-medium mb-1">裏側でAPIがやっていること</div>
<p class="text-sm text-ads-muted leading-relaxed">Google Maps APIが地図画像の取得、現在の交通情報の取得、経路計算をそれぞれ別のAPIに問い合わせ、統合して表示している</p>
</div>
</div>
</div>
<div class="mt-8 bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
</div>
<div>
<p class="font-bold text-ads-accent-light mb-1">気づきましたか?</p>
<p class="text-ads-muted leading-relaxed text-sm">
上の4つの例に共通しているのは、<strong class="text-slate-800">あなたがAPIの存在を意識していない</strong>ということです。天気を確認するとき「今からAPIを呼ぶぞ」とは思いませんよね。優れたAPIは、ユーザーにその存在を感じさせません。まるで空気のように、裏側で静かに仕事をしているのです。
</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 5: APIを使うとどう嬉しいか -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 flex-shrink-0">
<i data-lucide="trending-up" class="w-5 h-5 text-emerald-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIを使うとどう嬉しいか</h2>
</div>
<p class="mb-8 leading-relaxed">
ここまで読んで「APIは便利そうだ」と感じてもらえたと思います。では、開発者の視点から見たとき、APIを使うことで<strong class="text-slate-900">具体的にどのくらいの効果</strong>があるのか。数字と一緒に見てみましょう。
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
<div class="text-3xl md:text-4xl font-black text-ads-accent leading-tight mb-2">50回+</div>
<div class="text-sm text-ads-muted">あなたが1日に<br>APIを使っている回数</div>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
<div class="text-3xl md:text-4xl font-black text-emerald-600 leading-tight mb-2">24,000+</div>
<div class="text-sm text-ads-muted">世界で公開されている<br>APIの数</div>
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://www.programmableweb.com/" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">ProgrammableWeb</a></p>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
<div class="text-3xl md:text-4xl font-black text-amber-600 leading-tight mb-2">0.2秒</div>
<div class="text-sm text-ads-muted">多くのAPIの<br>平均応答時間</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="zap" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">開発スピードが上がる</h3>
<p class="text-sm text-ads-muted leading-relaxed">決済、認証、地図、翻訳...。これらをゼロから作ると何ヶ月もかかりますが、APIを使えば数日〜数時間で実装できます。車を作りたいとき、エンジンから設計する必要はないのです。</p>
</div>
</div>
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="shield" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">品質が担保される</h3>
<p class="text-sm text-ads-muted leading-relaxed">Google Maps、Stripe、AWSなど、各分野の専門企業が何千人体制で開発・運用しているAPIの品質は、個人や小さなチームで再現できるレベルではありません。その品質を「借りる」ことができます。</p>
</div>
</div>
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
<div class="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="refresh-cw" class="w-5 h-5 text-purple-600"></i>
</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">保守の手間が減る</h3>
<p class="text-sm text-ads-muted leading-relaxed">API提供元がバグ修正・機能改善・セキュリティ更新を継続的に行ってくれます。あなたはAPIを「使うだけ」。自分でゼロから作った機能は、自分でずっと面倒を見続ける必要があります。</p>
</div>
</div>
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="layers" class="w-5 h-5 text-emerald-600"></i>
</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">レゴのように拡張できる</h3>
<p class="text-sm text-ads-muted leading-relaxed">APIはレゴブロックのように組み合わせられます。たとえば「翻訳API + 音声合成API」を組み合わせれば、多言語音声読み上げ機能が作れます。1つのAPIだけでは実現できない価値が、組み合わせで生まれるのです。</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 6: よくある誤解 -->
<!-- ============================================================ -->
<section class="mb-16 md:mb-20">
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-red-500/10 flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">よくある誤解</h2>
</div>
<p class="mb-8 leading-relaxed">
APIについて学び始めると、多くの人が同じところでつまずきます。ここでは、初学者が陥りがちな3つの誤解を取り上げて、正しい理解に修正します。
</p>
<div class="space-y-4">
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
</div>
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIはプログラマーだけが使うもの」</h3>
</div>
<div class="px-6 py-5">
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
</div>
<div>
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
<p class="text-sm text-ads-muted leading-relaxed">
あなたも毎日APIを使っています。朝、天気アプリを開く。SNSにログインする。電子マネーで買い物する。これらの操作はすべて、裏側でAPIが動いています。プログラマーが「APIを使う」のは、この仕組みのコードを書いている側にいるだけの話。<strong class="text-slate-800">気づかないうちにAPIの恩恵を毎日受けている</strong>のです。
</p>
</div>
</div>
</div>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
</div>
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIって難しい技術でしょ</h3>
</div>
<div class="px-6 py-5">
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
</div>
<div>
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
<p class="text-sm text-ads-muted leading-relaxed">
APIの概念自体は「注文して結果を受け取る」というシンプルな仕組みです。レストランで注文できるなら、APIの概念は理解できます。先ほどのコード例のように、実際のプログラムも数行で書けることがほとんどです。難しいのはAPIそのものではなく、<strong class="text-slate-800">「APIで何を作るか」を考える部分</strong>。道具はシンプル、使いこなすセンスが問われるということです。
</p>
</div>
</div>
</div>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
</div>
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIを使うと個人情報が漏れそうで怖い」</h3>
</div>
<div class="px-6 py-5">
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
</div>
<div>
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
<p class="text-sm text-ads-muted leading-relaxed">
適切に設計されたAPIは、<strong class="text-slate-800">必要最小限の情報だけ</strong>をやり取りします。たとえば銀行のAPIが口座残高を返す際、パスワードや暗証番号は一切含まれません。APIはデータの「窓口」であり、<strong class="text-slate-800">「何の情報を公開し、何を隠すか」を厳密に制御</strong>できます。むしろ、データベースに直接触るよりもAPIを介した方が安全なのです。レストランのたとえで言えば、お客さんが直接厨房に入るより、ウェイターを通した方が厨房の秩序が保たれるのと同じです。
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 7: まとめ -->
<!-- ============================================================ -->
<section>
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-ads-accent/10 flex-shrink-0">
<i data-lucide="check-circle" class="w-5 h-5 text-ads-accent-light"></i>
</div>
<h2 class="text-xl md:text-2xl font-bold text-slate-900">まとめ — 覚えておきたい3つのこと</h2>
</div>
<p class="mb-8 leading-relaxed">
長い図解を読んでいただきありがとうございます。最後に、この記事で伝えたかったことを3つに絞ってまとめます。
</p>
<div class="space-y-4 mb-10">
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-blue-500">
<div class="flex items-start gap-4">
<div class="text-2xl font-black text-blue-500 leading-none flex-shrink-0 mt-1">01</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">APIは「ソフトウェアの窓口」</h3>
<p class="text-sm text-ads-muted leading-relaxed">
レストランのウェイターのように、あなた(アプリ)とサーバーの間を取り持つ仲介役。相手の内部構造を知らなくても、決まったルール(インターフェース)で話しかければ結果が返ってきます。
</p>
</div>
</div>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-emerald-500">
<div class="flex items-start gap-4">
<div class="text-2xl font-black text-emerald-500 leading-none flex-shrink-0 mt-1">02</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">あなたはすでにAPIユーザー</h3>
<p class="text-sm text-ads-muted leading-relaxed">
天気予報、SNSログイン、地図検索、オンライン決済。気づかないうちに、あなたの日常はAPIに支えられています。APIは特別な人だけのものではなく、全員の生活を支える仕組みです。
</p>
</div>
</div>
</div>
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-amber-500">
<div class="flex items-start gap-4">
<div class="text-2xl font-black text-amber-500 leading-none flex-shrink-0 mt-1">03</div>
<div>
<h3 class="font-bold text-slate-900 mb-2">APIで「車輪の再発明」がなくなる</h3>
<p class="text-sm text-ads-muted leading-relaxed">
すでにある優れた機能をAPIで借りることで、自分は「自分にしか作れない部分」に集中できます。開発スピードが上がり、品質も上がり、保守の手間も減る。これがAPIの最大の恩恵です。
</p>
</div>
</div>
</div>
</div>
<div class="text-center bg-gradient-to-b from-ads-accent/5 to-transparent border border-ads-accent/10 rounded-xl p-8 md:p-10">
<i data-lucide="globe" class="w-8 h-8 text-ads-accent mx-auto mb-4"></i>
<p class="text-lg font-bold text-slate-900 mb-3">APIは「知っている」だけで世界が広がる概念です。</p>
<p class="text-ads-muted max-w-lg mx-auto leading-relaxed">
次にアプリを使うとき、「この裏側でどんなAPIが動いているんだろう」と想像してみてください。天気予報の数字も、ログインボタンも、決済完了の画面も、すべてAPIが繋いでいます。テクロジーの見え方が、少しだけ変わるはずです。
</p>
</div>
</section>
<!-- CONTENT_END -->
</main>
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
<p class="text-xs text-ads-dim text-center">AI-Driven School の図解ツールで作成</p>
</footer>
<script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script>
</body>
</html>

View File

@ -0,0 +1,68 @@
# Node.js インストールガイド
図解の公開ステップやセットアップの前提確認で Node.js が未インストールだった場合に参照する。
## 説明と許可
以下をユーザーに伝え、インストールの許可を求める:
> 図解の作成は完了しました。`output/` フォルダ内の HTML ファイルをブラウザにドラッグ&ドロップすれば、今すぐ確認できます。
>
> URLで公開するには「Node.js」のインストールが必要です。
> Node.js は、パソコン上でプログラムを動かすための土台で、世界中で使われている安全な道具です。
> **README にも記載の通り、AI-Driven School 運営が確認済みですので安心してください。**
>
> 今からインストールしてもよいですか?
ユーザーが許可しなかった場合 → ファイルの確認方法(生成された HTML ファイルをブラウザで開く)を伝えて終了。
## macOS の場合
インストーラーをダウンロードする:
```bash
PKG_NAME=$(curl -sL https://nodejs.org/dist/latest-lts/ | grep -o 'node-v[0-9.]*\.pkg' | head -1) && curl -fsSL "https://nodejs.org/dist/latest-lts/${PKG_NAME}" -o /tmp/node-install.pkg && echo "ダウンロード完了: ${PKG_NAME}"
```
ダウンロード完了後、インストールを実行する**前に**以下を伝える:
> インストールのために、パソコンのパスワードの入力が必要です。
> これはパソコンにログインするときに使っているパスワードです。
> 画面下のターミナル欄にパスワードを入力して Enter を押してください。
> 入力中の文字は画面に表示されませんが、正常な動作です。
```bash
sudo installer -pkg /tmp/node-install.pkg -target / && rm /tmp/node-install.pkg
```
## Windows の場合
インストールを実行する**前に**以下を伝える:
> インストール中に「このアプリがデバイスに変更を加えることを許可しますか?」という確認画面が表示されることがあります。
> 「はい」を押してください。
```powershell
winget install OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements
```
インストール完了後、現在のターミナルで Node.js を使えるようにする:
```powershell
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
```
winget が使えない場合「winget は認識されていません」と表示された場合):
```powershell
$msi = (Invoke-WebRequest -Uri "https://nodejs.org/dist/latest-lts/" -UseBasicParsing).Links.href | Where-Object { $_ -match "x64\.msi$" } | Select-Object -First 1; Invoke-WebRequest -Uri "https://nodejs.org/dist/latest-lts/$msi" -OutFile "$env:TEMP\node-install.msi" -UseBasicParsing; Start-Process msiexec.exe -ArgumentList "/i `"$env:TEMP\node-install.msi`"" -Verb RunAs -Wait; Remove-Item "$env:TEMP\node-install.msi"
```
## インストール完了の確認
```bash
node --version
```
バージョン番号が表示された → インストール成功。
エラーが出た → Cursor を再起動してからもう一度試すよう案内する。

View File

@ -0,0 +1,125 @@
#!/bin/bash
# 図解 HTML にコメントウィジェットの script タグを注入して surge.sh に公開する。
#
# 使い方: deploy-diagram.sh <HTMLファイル> [スラッグ]
#
# ウィジェットの配信元 URL とトークンは、セットアップスキルが
# リポジトリルートに生成する fb-tool-url.txt / fb-api-token.txt から読む。
set -e
HTML_FILE="${1:?使い方: deploy-diagram.sh <HTMLファイル> [スラッグ]}"
SLUG="${2:-}"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
if ! command -v node &>/dev/null; then
echo -e "${RED}エラー: Node.js がインストールされていません${NC}" >&2
echo "Node.js をインストールしてから、もう一度試してください。" >&2
exit 1
fi
if [ ! -f "$HTML_FILE" ]; then
echo -e "${RED}エラー: $HTML_FILE が見つかりません${NC}" >&2
echo "先に図解を生成してください。" >&2
exit 1
fi
if [ -n "$SLUG" ]; then
DOMAIN="diagram-${SLUG}.surge.sh"
else
DOMAIN="diagram-$(date +%y%m%d%H%M).surge.sh"
fi
# このスクリプトは <ルート>/.claude/skills/creating-visual-explainers/scripts/ に置かれる前提。
# 4階層上 = このスキル一式が置かれているリポジトリのルート
ROOT_DIR="$(cd "$(dirname "$0")/../../../.." && pwd)"
FB_URL_FILE="$ROOT_DIR/fb-tool-url.txt"
if [ ! -f "$FB_URL_FILE" ]; then
echo -e "${RED}エラー: fb-tool-url.txt が見つかりません${NC}" >&2
echo "チャット欄で「セットアップして」と伝えてください。" >&2
exit 1
fi
FB_URL=$(cat "$FB_URL_FILE")
FB_TOKEN_FILE="$ROOT_DIR/fb-api-token.txt"
if [ ! -f "$FB_TOKEN_FILE" ]; then
echo -e "${RED}エラー: fb-api-token.txt が見つかりません${NC}" >&2
echo "セットアップが完了していない可能性があります。チャット欄で「セットアップして」と伝えてください。" >&2
exit 1
fi
API_TOKEN=$(cat "$FB_TOKEN_FILE")
if [[ ! "$FB_URL" =~ ^https:// ]]; then
echo -e "${RED}エラー: fb-tool-url.txt の URL が https:// で始まっていません${NC}" >&2
exit 1
fi
if [[ "$FB_URL" =~ [\\|\&$'\n'] ]]; then
echo -e "${RED}エラー: fb-tool-url.txt に不正な文字が含まれています${NC}" >&2
exit 1
fi
if [[ "$API_TOKEN" =~ [\\|\&$'\n'\ ] ]]; then
echo -e "${RED}エラー: fb-api-token.txt に不正な文字が含まれています${NC}" >&2
exit 1
fi
if ! grep -q '</body>' "$HTML_FILE"; then
echo -e "${RED}エラー: $HTML_FILE に </body> タグが見つかりません${NC}" >&2
echo "HTML ファイルの構造が壊れている可能性があります。" >&2
exit 1
fi
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
SNIPPET="<script src=\"${FB_URL}/widget.js\" data-token=\"${API_TOKEN}\"></script></body>"
# 最後の </body> だけを置換する。図解の題材が HTML やコードだと本文中にも
# </body> が現れうるため、一括置換すると script タグが複数挿し込まれて壊れる。
awk -v snippet="$SNIPPET" '
{ line[NR] = $0; if (index($0, "</body>")) last = NR }
END {
for (i = 1; i <= NR; i++) {
if (i == last) {
# 該当行の最後の </body> を置換するsub は先頭一致なので位置を自力で求める)
s = line[i]; pos = 0; from = 1
while ((p = index(substr(s, from), "</body>")) > 0) {
pos = from + p - 1
from = pos + 7
}
printf "%s%s%s\n", substr(s, 1, pos - 1), snippet, substr(s, pos + 7)
} else {
print line[i]
}
}
}
' "$HTML_FILE" > "$TEMP_DIR/index.html"
printf "User-agent: *\nDisallow: /\n" > "$TEMP_DIR/robots.txt"
echo -e "${YELLOW}公開中...${NC}"
npx --yes surge "$TEMP_DIR" --domain "$DOMAIN"
touch "$ROOT_DIR/deploy-history.log"
echo "$(date '+%Y-%m-%d %H:%M:%S') | https://${DOMAIN}" >> "$ROOT_DIR/deploy-history.log"
echo ""
echo -e "${GREEN}完了!${NC}"
echo "URL: https://${DOMAIN}"
if [[ "$OSTYPE" == "darwin"* ]]; then
echo "https://${DOMAIN}" | pbcopy
echo -e "${GREEN}URLをクリップボードにコピーしました${NC}"
open "https://${DOMAIN}"
elif command -v clip.exe &>/dev/null; then
echo -n "https://${DOMAIN}" | clip.exe
echo -e "${GREEN}URLをクリップボードにコピーしました${NC}"
start "https://${DOMAIN}" 2>/dev/null || true
elif command -v xdg-open &>/dev/null; then
xdg-open "https://${DOMAIN}"
fi
echo -e "${YELLOW}削除するとき: npx surge teardown ${DOMAIN}${NC}"

View File

@ -0,0 +1,294 @@
---
name: setting-up-comment-tool
description: 音声図解コメントツールの初期セットアップVercel・Neon・Gemini APIキーを対話的にガイドするスキル。「セットアップして」「セットアップの続きをして」「コメントツールをセットアップして」「フィードバック機能を設定して」「音声図解コメントツールを使えるようにして」と依頼された際、および「コメントが保存されません」などセットアップ起因のトラブル調査を求められた際に使用する。
---
# Setting Up Comment Tool
図解に対するコメント機能テキスト引用・ピン・音声を使えるようにする初期セットアップ。Vercelホスティング・Neon Postgresデータベース・Gemini APIキー音声解析の設定を対話的にガイドする。
**実行するのは1回だけ。** セットアップ完了後は、図解を作ってsurge.shにデプロイするだけで自動的にコメント機能が付く。
## 用語の定義
- **ツールフォルダ**: このツール本体(`package.json` の name が `voice-diagram-comment-tool` のフォルダ)。`npm` / `vercel` コマンドはすべてここで実行する
- **リポジトリルート**: `.claude` フォルダがある階層。`fb-tool-url.txt` / `fb-api-token.txt` はここに保存する(デプロイスクリプトがここから読むため)
## Step 0: ツールフォルダの特定
1. ワークスペースのルートに `package.json` があり、name が `voice-diagram-comment-tool` なら、ルートがツールフォルダ(単体利用)
2. なければ、ワークスペース内から name が `voice-diagram-comment-tool``package.json` を探す(別リポジトリに組み込んだ利用。フォルダ名は通常 `voice-diagram-comment`
3. 見つからない場合、以下を伝えて終了:
> ツール本体のフォルダが見つかりません。「voice-diagram-comment」音声図解コメントツールフォルダがこのリポジトリ内にあるか確認してください。
## 前提確認
### Node.js
```bash
node --version
```
バージョン番号が表示された → 次に進む。
`command not found``.claude/skills/creating-visual-explainers/references/node-install-guide.md` の手順でインストールを案内する。
### Vercel CLI
```bash
vercel --version
```
バージョン番号が表示された → 次に進む。
`command not found` → 以下を実行:
```bash
npm install -g vercel
```
## ワークフロー
以降のコマンドは、明記がない限り**ツールフォルダで実行する**。
### Step 1: 依存関係のインストール
```bash
npm install
```
### Step 2: Vercelにログイン
```bash
vercel login
```
ブラウザが開く。以下を伝える:
> ブラウザでVercelのログイン画面が開きます。
> アカウントを持っていない場合は「Sign Up」から無料アカウントを作成してください。
> メールアドレスまたはGitHubアカウントで登録できます。
> ログインが完了したら、ターミナルに戻ってください。
### Step 3: Vercelに初回デプロイ
先にデプロイしてVercelプロジェクトを作成する環境変数の設定にはプロジェクトが必要なため
自動命名に任せると名前の衝突などで不正になることがある。最初にプロジェクト名を明示してリンクする:
```bash
vercel link --yes --project voice-diagram-comment-tool
```
(同名プロジェクトが既にある等でエラーになったら、`voice-comment-tool-2` のように名前を変えて再実行する)
続いて本番デプロイ:
```bash
vercel --yes --prod
```
デプロイが完了すると以下の2つのURLが出力される。両方を控えておく:
- **Inspect URL**: `https://vercel.com/{slug}/{project}/...` 形式Step 6 で使う)
- **Production URL**: `https://xxx.vercel.app` 形式最終的なツールURL
この時点では環境変数が未設定なのでツールはまだ動かない。そのまま次に進む。
### Step 4: APIトークンの生成と設定
APIを保護するためのトークンを生成し、Vercelの環境変数に設定する。
```bash
openssl rand -hex 16
```
**WindowsPowerShellの場合:**
```powershell
[System.Guid]::NewGuid().ToString("N")
```
表示された文字列がトークン。これを環境変数として設定する:
```bash
echo "生成したトークン" | vercel env add API_TOKEN production
```
「生成したトークン」は実際に生成した値に置き換える。`vercel env add` が対話式プロンプトを出さずに値を受け取るよう、パイプで渡す。
同じトークンを**リポジトリルート**の `fb-api-token.txt` に保存する1行、トークンのみ
### Step 5: Gemini APIキーの設定
音声フィードバック喋った内容をAIが聞き取って図解にピンを配置する機能に使うAPIキーを設定する。
ユーザーに以下を案内する:
> 音声フィードバックには Google の Gemini というAIを使います。無料のAPIキーを発行しましょう。
>
> 1. ブラウザで https://aistudio.google.com/apikey を開く
> 2. Googleアカウントでログイン
> 3. 「APIキーを作成」Create API keyをクリック
> 4. 表示されたキー(`AIza...` で始まる文字列)をコピー
> 5. コピーしたキーをこのチャット欄に貼り付けてください
>
> 無料枠の範囲で使えます。クレジットカードの登録は不要です。
キーを受け取ったら設定する:
```bash
echo "受け取ったAPIキー" | vercel env add GEMINI_API_KEY production
```
**注意**: 受け取ったAPIキーは環境変数の設定にだけ使う。ファイルに書き出したり、チャットの返答で復唱したりしない。
### Step 6: データベースの追加
Step 3 の Inspect URL からデプロイID部分末尾のランダム文字列を削り、`/stores` を付けて Storage ページの直接URLを組み立てる。
例: Inspect URL が `https://vercel.com/your-projects/voice-diagram-comment-tool/abc123xyz` なら
`https://vercel.com/your-projects/voice-diagram-comment-tool/stores`
ユーザーにブラウザでの操作を案内する。以下の `{StorageページURL}` を組み立てたURLに置き換えて伝える:
> コメントを保存するデータベースを追加します。ブラウザで以下のURLを開いてください。
>
> {StorageページURL}
>
> ページが開いたら、以下の操作をしてください。
>
> 1. 「Create Database」をクリック
> 2. 「Neon Postgres」を選択
> 3. プランは「Free」を選択無料、クレジットカード不要
> 4. 「Create」をクリック
> 5. 次の画面で:
> - 「Search Projects」からプロジェクトを選択
> - 「Custom Prefix」の欄を `DATABASE` に変更
> - 「Connect」をクリック
>
> 完了したら教えてください。
### Step 7: 環境変数の取得とマイグレーション
```bash
vercel env pull .env.local
```
テーブルを作成:
```bash
npm run db:migrate
```
`Migration complete.` と表示されれば成功。
### Step 8: 再デプロイ
環境変数を反映するため、もう一度デプロイする。
```bash
vercel --prod
```
### Step 9: URLを保存する
ツールの**固定URL**`https://プロジェクト名.vercel.app` 形式。通常は `https://voice-diagram-comment-tool.vercel.app`)を、**リポジトリルート**の `fb-tool-url.txt` に書き出す。URLのみを1行で保存する。
**注意**: `vercel --prod` の出力に表示されるランダム文字列入りのURL例: `https://voice-diagram-comment-tool-a1b2c3-xxx.vercel.app`は保存しない。デプロイ固有URLはVercelの保護機能により外部から読めないことがある。
保存したら、公開ページから widget.js に到達できることを確認する:
```bash
curl -s -o /dev/null -w "%{http_code}" "$(cat fb-tool-url.txt)/widget.js"
```
**WindowsPowerShellの場合:**
```powershell
(Invoke-WebRequest "$((Get-Content fb-tool-url.txt).Trim())/widget.js").StatusCode
```
`200` ならOK。`404` や `401` が返る場合は、Vercelダッシュボードのプロジェクト → Settings → Domains に表示されているドメインを確認し、そのURLで `fb-tool-url.txt` を保存し直して再確認する。
### Step 10: 音声解析APIの通し検証
widget.js は静的ファイルなので、これだけではAPIサーバーレス関数が動いている保証にならない。無音の音声データを実際に `/api/voice-analyze` へ送り、Gemini まで通ることを確認する。
まず、16kHz・モラル・1秒の無音WAVを生成してbase64化し、リクエストボディを組み立てる**リポジトリルート**で実行):
```bash
node -e '
const sr = 16000, dataLen = sr * 2; // 16bit mono 1秒
const b = Buffer.alloc(44 + dataLen);
b.write("RIFF", 0); b.writeUInt32LE(36 + dataLen, 4); b.write("WAVE", 8);
b.write("fmt ", 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(sr, 24); b.writeUInt32LE(sr * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
b.write("data", 36); b.writeUInt32LE(dataLen, 40);
const body = {
audioBase64: b.toString("base64"),
mimeType: "audio/wav",
targets: [{ index: 0, text: "テスト", heading: "テスト", isHeading: true }],
};
require("fs").writeFileSync("/tmp/fb-voice-check.json", JSON.stringify(body));
'
```
**WindowsPowerShellの場合**: 上と同じ `node -e` スクリプトを実行し、出力先だけ `$env:TEMP + "\\fb-voice-check.json"` に読み替える(`node -e` はWindowsでも動く
続いて、APIトークンを付けてPOSTする:
```bash
curl -s -w "\n%{http_code}" -X POST "$(cat fb-tool-url.txt)/api/voice-analyze" \
-H "Authorization: Bearer $(cat fb-api-token.txt)" \
-H "Content-Type: application/json" \
--data @/tmp/fb-voice-check.json
```
結果の判定:
- **HTTP 200 で `{"results":[]}`**(無音なので空配列)→ 音声解析まで通しで動いている。セットアップ成功
- **HTTP 404** → APIルートがデプロイされていない。Vercelのビルド設定の問題エラー対応の「音声の解析に失敗しました」を参照
- **`Gemini エラー` を含む応答** → Geminiのモデル名またはAPIキーの問題エラー対応の「Gemini エラー (404)」を参照)
- **HTTP 403**`fb-api-token.txt` の値と Vercel の `API_TOKEN` が食い違っている。Step 4 をやり直す
### Step 11: 完了報告
リポジトリルートに `fb-tool-url.txt``fb-api-token.txt` があることを確認し、以下を伝える:
```
セットアップ完了
あなたのコメントツール URL:
https://xxx.vercel.app
以降「図解を作って」と伝えるだけで、コメント機能付きの図解が公開されます。
公開した図解では、3通りの方法でフィードバックできます:
- テキストをマウスで選択してコメント
- 好きな場所にピンを刺してコメント
- マイクに向かって喋るだけAIが該当箇所にピンを自動配置
APIはトークンで保護されています。図解のデプロイ時に自動で埋め込まれるため、
追加の操作は不要です。
```
`fb-tool-url.txt` / `fb-api-token.txt` / `.env.local` はGitにコミットしないこのフォルダの `.gitignore` で除外済み。別リポジトリに組み込んでいる場合は、そのリポジトリの `.gitignore` にも追加するよう案内する)。
## エラー対応
エラーメッセージをそのまま見せず、何が起きていて何をすれば解決するかを平易に説明する。
- **`vercel: command not found`** → `npm install -g vercel` を実行
- **`DATABASE_URL is not set`** → Step 6のデータベース追加が完了しているか確認。完了していれば `vercel env pull .env.local` を再実行
- **マイグレーション失敗** → ツールフォルダの `.env.local``DATABASE_URL` が含まれているか確認
- **音声フィードバックで「GEMINI_API_KEY が未設定です」** → Step 5が完了しているか `vercel env ls` で確認し、設定後に `vercel --prod` で再デプロイ
- **「音声の解析に失敗しました」** → まず `/api/voice-analyze` が404を返していないか確認する`curl -s -o /dev/null -w "%{http_code}" -X POST "$(cat fb-tool-url.txt)/api/voice-analyze"` — 404以外APIは存在する。404ならAPIルートがデプロイされていない。原因はVercelのビルド設定: `vercel.json``buildCommand` を直接書くとVercelがNext.jsを認識できず、APIが生成されない。ツールフォルダの `vercel.json``{"framework": "nextjs"}` のみで、`package.json` に `vercel-build` スクリプトがあることを確認し、`vercel --prod` で再デプロイする
- **「Gemini エラー (404)」** → 指定中のGeminiモデルが新規APIキーでは利用不可になっている例: `gemini-2.5-flash` は新規ユーザーに提供終了)。`echo "gemini-flash-latest" | vercel env add GEMINI_MODEL production` で環境変数を設定し、`vercel --prod` で再デプロイする(ツールが最新版なら既定値が `gemini-flash-latest` のためこのエラーは起きない)
- **「コメントが保存されません」と相談された** → 順に切り分ける: ①Step 9の到達確認widget.js が200か②`vercel env ls` で `DATABASE_URL``API_TOKEN` が production にあるか ③マイグレーション済みか(`vercel env pull .env.local` → `npm run db:migrate`)④環境変数を後から足した場合は `vercel --prod` で再デプロイしたか
## 依存
- ツールフォルダの `package.json` — 依存関係と `db:migrate` スクリプト
- ツールフォルダの `scripts/migrate.ts` — DBマイグレーションスクリプト
- `.claude/skills/creating-visual-explainers/references/node-install-guide.md` — Node.jsインストール手順
- `.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh``fb-tool-url.txt` / `fb-api-token.txt` の読み取り側

19
.env.example Normal file
View File

@ -0,0 +1,19 @@
# ローカルで APInpm run devを動かすときに使う環境変数のテンプレート。
# このファイルを .env.local にコピーして、各値を自分のものに置き換える。
# 通常は `vercel env pull .env.local` で Vercel から取得すればよく、手で書く必要はない。
#
# 本番Vercelには同じ変数を Vercel プロジェクトの環境変数として設定する。
# 設定手順はセットアップスキル(「セットアップして」)が案内する。
# Neon Postgres への接続文字列。Vercel の Storage 連携で自動的に設定される
DATABASE_URL=
# API を保護する合言葉。セットアップ時に自動生成されるfb-api-token.txt と同じ値)
# ローカル開発では未設定でも動く
API_TOKEN=
# 音声解析に使う Google AI Studio の API キーhttps://aistudio.google.com/apikey で無料発行)
GEMINI_API_KEY=
# 任意。未指定なら gemini-flash-latest常に最新のflash系を指す公式エイリアスが使われる
# GEMINI_MODEL=

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# dependencies
/node_modules
/.pnp
.pnp.*
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env filesシークレットは絶対にコミットしない。テンプレートの .env.example だけは配布に含める)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# 図解の生成物・セットアップで生成されるファイル
output/*.html
deploy-history.log
fb-tool-url.txt
fb-api-token.txt
.env*.local

259
README.md Normal file
View File

@ -0,0 +1,259 @@
# 音声図解コメントツール
わからない言葉やしくみを AI に伝えるだけで、噛み砕いた図解ページを自動で作って公開できるツールです。公開した図解には、見た人がコメントを付けられます。コメントの付け方は3通りあります。
- **テキストを選んでコメント** — 気になる箇所をマウスで選択すると、引用付きでコメントできます
- **ピンを刺してコメント** — 図の好きな場所をクリックしてピンを立て、コメントできます
- **マイクに向かって喋るだけ** — 「上の説明はもっとシンプルに。真ん中の図は矢印が逆」と喋ると、AIGeminiが聞き取って、図解の該当箇所にピンを自動で配置します
さらに、こんな特徴があります。
- **優先度が色でわかる** — Must・Better・Wantの3段階で重要度を伝えられます
- **セットアップは1回だけ** — 一度設定すれば、以降は図解を公開するたびにコメント機能が自動で付きます
- **無料で使える** — Vercel・Neon・Surge・Gemini API のすべて無料プランで動きます。クレジットカードの登録は不要です
## しくみ
このツールは2つの部品でできています。
```
「○○を図解して」と AI に依頼
① 図解 HTMLoutput/ に生成 → Surge に公開される静的ページ)
│ デプロイ時に </body> の直前へ1行だけ自動注入される
<script src="{あなたのツールURL}/widget.js" data-token="xxx"></script>
② バックエンド(あなたの Vercel で常時動く Next.js アプリ)= このフォルダ
├─ widget.js … コメント UI 本体(画面右端に出るパネル)
├─ /api/comments … コメントの保存・取得・更新・削除
└─ /api/voice-analyze … 音声を Gemini に渡してピン配置情報に変換
Neon Postgrescomments テーブル)
```
②は1つ動いていればOKです。図解を作るたびに何かをセットアップする必要はありません。
## セットアップ
初回だけ必要な設定です。AI が順番に案内してくれます。
```
① Node.js / Vercel CLI を確認
② Vercel にログイン・デプロイ
③ API トークンを自動生成
④ Gemini API キーを発行(音声機能用・ブラウザで操作)
⑤ データベースを追加(ブラウザで操作)
⑥ マイグレーション実行・再デプロイ
⑦ 完了fb-tool-url.txt と fb-api-token.txt が生成される)
```
### やり方
このフォルダを Cursor で開き、チャット欄で次のように伝えてください。
```
セットアップして
```
AI がステップごとに案内してくれます。
### 必要なもの
| 必要なもの | 説明 | 費用 |
| ------------- | ---------------------------------------- | ------------- |
| Node.js | パソコン上でプログラムを動かすための土台。AI が自動でインストールを案内します | 無料 |
| Vercel アカウント | コメント機能を動かすサーバー。メールアドレスで登録できます | 無料Hobby プラン) |
| Neon Postgres | コメントデータを保存するデータベース。Vercel の画面から追加します | 無料 |
| Google アカウント | 音声を聞き取る AIGeminiの API キー発行に使います | 無料 |
| Surge アカウント | 図解を公開するサービス。初回にメールアドレスとパスワードで登録します | 無料 |
アカウントを持っていなくても大丈夫です。セットアップの途中で AI が「今このアカウントを作ってください」と、必要になったタイミングで案内します。
### Gemini API キーの発行手順(音声機能に必要)
API キーとは、AI サービスを利用するための「あなた専用の合鍵」です。発行したことがなくても、以下の4ステップ・3分程度で作れます。セットアップ中に AI がこの手順を案内するので、事前にやっておく必要はありません。
1. ブラウザで [Google AI Studio](https://aistudio.google.com/apikey) を開く
2. Google アカウントでログインする
3. 「API キーを作成」Create API keyボタンを押す
4. 表示された `AIza...` で始まる文字列がキーです。セットアップ中に AI から求められたら、チャット欄に貼り付けてください
無料枠の範囲で使えます。クレジットカードの登録は不要です。
### 大事な注意
- **API キーやトークンは自分専用です。** 他の人と共有したり、チャットや SNS に貼ったりしないでください
- セットアップで生成される `fb-tool-url.txt`・`fb-api-token.txt`・`.env.local` は Git にコミットされない設定になっています(`.gitignore` で除外済み)
- 図解は URL を知っていれば誰でも見られます。**機密情報を図解に載せない**前提で使ってください
## 使い方
### 図解を作る
Cursor のチャット欄に、知りたいことを書いて送信します。
```
APIについて図解して
```
AI が図解を作り、コメント機能付きで自動デプロイします。
### コメントを付ける(見る側)
公開された図解を開くと、画面右端にボタンが出ます。ボタンには3つの機能が並んでいます。
| アイコン | 機能 |
| ---- | -------------------- |
| パネル | サイドバーの開閉。コメント一覧を見る |
| ピン | フィードバックモードの ON / OFF |
| マイク | 音声フィードバックの録音開始 / 停止 |
**音声で伝える**: マイクアイコンを押して喋り、もう一度押して停止します。数秒後、AI が「どの見出しへの指摘か」を判定して、該当箇所にピンを自動で配置します。初回はブラウザがマイクの使用許可を求めるので「許可」を選んでください。
**ピンで伝える**: ピンアイコンで フィードバックモードを ON にすると、クリックでピン、ドラッグでテキスト引用コメントになります。`Esc` でモードを抜けます。
初回だけ名前の入力を求められます(次回以降は自動で記憶されます)。
### 優先度の意味
| 優先度 | 色 | 使いどころ |
| ------ | --- | -------------------------- |
| Must | 赤 | 必ず直すべき箇所(事実の間違い、重大なわかりにくさ) |
| Better | 黄 | 直すとよくなる箇所(表現の改善、構成の入れ替え) |
| Want | 緑 | 余裕があれば対応(細かい表現、好みの範囲) |
### コメントを確認・管理する
画面右端のパネルボタンでサイドバーが開きます。
- **未解決 / 解決済 / すべて** のフィルタで絞り込めます
- コメントカードと本文の該当箇所は相互にジャンプできます
- 返信・解決・編集・削除・優先度変更ができます
- サイドバーのボタンから **JSON / Markdown** でエクスポートできます
### 図解の削除
チャット欄で「この図解を削除して」と伝えてください。AI がデプロイ履歴(`deploy-history.log`。公開のたびに自動で記録されます)から URL を特定し、削除を実行します。
## 普段使っているリポジトリに組み込む
このフォルダを単体で使うだけでなく、**普段開発しているリポジトリの中にこのツールを入れて使う**ことができます。ファイルを手で動かす必要はありません。組み込み作業は AI に任せます。
### 手順
1. このツールのフォルダ(`voice-diagram-comment`を、パソコンの好きな場所に置きますクローンまたはダウンロード。すでにあるならそのままでOK
2. 組み込みたいリポジトリを Cursor で開きます
3. 下のプロンプトをチャット欄に貼り付け、**1行目のパスだけ自分の環境に書き換えて**送信します
```
「音声図解コメントツール」をこのリポジトリに組み込んでください。
ツールの場所: (ここにフォルダのパスを書く。
Mac の例: /Users/あなたの名前/Downloads/voice-diagram-comment
Windows の例: C:\Users\あなたの名前\Downloads\voice-diagram-comment
やってほしいこと:
1. 上記フォルダを、このリポジトリ内の tools/voice-diagram-comment/ にコピーする
node_modules・.next・.vercel・.env.local はコピーしない。.env.example はコピーしてよい)
2. コピーしたフォルダ内の .claude/skills/ にある2つのスキルフォルダを、
このリポジトリ直下の .claude/skills/ に移動する
(スキルはリポジトリのルートにないと Cursor に認識されないため)
3. このリポジトリの .gitignore に、トークンファイルと生成物の除外設定を追加する
(除外すべきものは、コピーしたフォルダ内の .gitignore に書いてある。
パスは実際の配置に合わせて調整する)
4. コピー元のフォルダ直下に fb-tool-url.txt と fb-api-token.txt があれば、
このリポジトリのルートにコピーする(セットアップ済み環境の引き継ぎ)
5. 終わったら、組み込んだ内容と「次に何をすればいいか」を教えてください
fb-tool-url.txt が無い場合は、次に「セットアップして」と伝えれば
初期セットアップが始まる、と案内してください)
```
4. AI の作業が終わったら、案内に従って「セットアップして」と伝えますすでに単体でセットアップ済みの場合は不要です。Vercel の再デプロイも不要です)
### 組み込むとこうなる
```
あなたのリポジトリ/
├── src/ ... ← 既存のファイルはそのまま
├── .claude/skills/ ← スキルはルートに置かれる
│ ├── setting-up-comment-tool/
│ └── creating-visual-explainers/
├── tools/
│ └── voice-diagram-comment/ ← ツール本体
├── output/ ← 図解の生成先(自動作成)
├── fb-tool-url.txt ← セットアップで生成
└── fb-api-token.txt ← セットアップで生成
```
以降は、そのリポジトリのチャット欄で「図解を作って」と伝えるだけで、コメント機能付きの図解が公開できます。
## フォルダの中身
```
voice-diagram-comment/
├── .claude/skills/
│ ├── creating-visual-explainers/ ← 図解生成スキル
│ │ ├── SKILL.md
│ │ ├── references/ ← 図解テンプレート・模範回答・Nodeインストールガイド
│ │ └── scripts/
│ │ └── deploy-diagram.sh ← デプロイwidget 自動注入
│ └── setting-up-comment-tool/ ← セットアップスキル
│ └── SKILL.md
├── output/ ← 図解の保存先
├── public/
│ ├── widget.js ← コメント UI 本体(ビルド成果物)
│ └── feedback-demo.html ← ローカル動作確認用デモ
├── src/
│ ├── app/api/comments/ ← コメント CRUD API
│ ├── app/api/voice-analyze/ ← 音声解析 APIGemini
│ └── lib/ shared/ widget/ ← DB接続・共通処理・コメント UI のソースコード
├── tests/ ← 自動テスト
├── scripts/
│ └── migrate.ts ← DB テーブル作成セットアップ時に1回だけ実行
├── .env.example ← 環境変数のテンプレート(実際の値は書かない)
├── package.json
└── README.md ← この説明書
```
セットアップ完了後に `fb-tool-url.txt`(ツールの URL`fb-api-token.txt`API トークン)がリポジトリのルートに自動生成されます。あなたが手で編集する必要のあるファイルはありません。
## 困ったとき
| 症状 | 原因と対処 |
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
| 「セットアップして」と言っても反応しない | スキルが認識されていません。このフォルダ自体(または組み込み先リポジトリのルート)を Cursor で開いているか、`.claude/skills/` がルート直下にあるか確認してください |
| マイクを押すと「マイクへのアクセスが許可されませんでした」 | ブラウザのマイク権限を確認。`https://` のページでないと許可されませんSurge の URL は https なので通常OK |
| 音声で「ピンを置ける要素が見つかりませんでした」 | 図解 HTML に見出しがありません。このツールのスキルで作った図解なら自動で入ります |
| 音声で「GEMINI_API_KEY が未設定です」 | Gemini API キーの設定が漏れています。チャット欄で「セットアップの続きをして」と伝えてください |
| コメントが保存されない | データベースの設定が未完了の可能性。チャット欄で「コメントが保存されません」と伝えると AI が原因を調べます |
| 公開済みの図解にコメント機能が付かない | コメント機能はデプロイ時に注入されます。「この図解を再デプロイして」と伝えてください |
| その他 | 何が起きても、まずはチャット欄で AI に状況を伝えてください。エラーの内容を読み取って、次にやるべきことを案内してくれます |
---
Mac / Windows どちらでも使えます。AI-Driven School 運営が動作確認済みです。

20
eslint.config.mjs Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// esbuild が生成するバンドル。lint 対象にすると実コードの警告が埋もれる
"public/widget.js",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

8225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "voice-diagram-comment-tool",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"vercel-build": "npm run build:widget && next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"build:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --minify",
"dev:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --watch",
"db:migrate": "npx tsx scripts/migrate.ts"
},
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/jsdom": "^28.0.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"esbuild": "^0.27.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^27.0.1",
"typescript": "5.9.3",
"vitest": "^3.2.4"
}
}

111
public/feedback-demo.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>HTTPリクエストの流れフィードバック確認用</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
ads: {
bg: '#FFFFFF',
surface: '#F8FAFC',
hover: '#F1F5F9',
border: '#E2E8F0',
accent: '#3B82F6',
'accent-light': '#2563EB',
text: '#1E293B',
muted: '#64748B',
dim: '#94A3B8',
positive: '#10B981',
negative: '#EF4444',
warning: '#F59E0B',
}
},
fontFamily: {
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
}
}
}
};
</script>
</head>
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 bg-ads-accent/10 text-ads-accent-light px-4 py-1.5 rounded-full text-sm font-medium mb-6">
フィードバック確認用サンプル
</div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 tracking-tight mb-4">
<span class="text-ads-accent-light">HTTP</span>リクエストの流れ
</h1>
<p class="text-lg text-ads-muted max-w-xl mx-auto">
テキストを選択すると、右下のフィードバックボタンからコメントできます。
</p>
</div>
<div class="bg-ads-surface border border-ads-border rounded-2xl p-6 md:p-8 mb-6">
<h2 class="text-xl font-bold text-slate-900 mb-6 text-center">4ステップで理解する</h2>
<div class="space-y-4">
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">1</div>
<div>
<h3 class="font-bold text-slate-900 mb-1">ブラウザがリクエストを送る</h3>
<p class="text-sm">ユーザーがURLを開くと、ブラウザはサーバーへ「このページをください」とHTTPリクエストを送ります。</p>
</div>
</div>
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">2</div>
<div>
<h3 class="font-bold text-slate-900 mb-1">サーバーが処理する</h3>
<p class="text-sm">Webサーバーはリクエストを受け取り、必要なファイルやデータを探して応答内容を組み立てます。</p>
</div>
</div>
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">3</div>
<div>
<h3 class="font-bold text-slate-900 mb-1">レスポンスが返る</h3>
<p class="text-sm">サーバーはHTTPレスポンスとしてHTMLやJSON、画像などをブラウザへ返します。ステータスコード200は成功を意味します。</p>
</div>
</div>
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">4</div>
<div>
<h3 class="font-bold text-slate-900 mb-1">ブラウザが表示する</h3>
<p class="text-sm">ブラウザは受け取った内容を解釈し、画面上にページとして描画します。CSSやJavaScriptもここで適用されます。</p>
</div>
</div>
</div>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-xl p-5 mb-6">
<p class="text-sm text-amber-900 font-medium mb-2">このページの使い方</p>
<ul class="text-sm text-amber-900/90 space-y-1 list-disc list-inside">
<li>画面右端中央のボタン → フィードバックサイドバーを開く</li>
<li>本文テキストをドラッグ選択 → 引用付きコメントを追加</li>
<li>サイドバーのピン・マイクボタン → ピン留めコメント/音声フィードバックを試せる</li>
<li>ローカルの <code class="bg-white/70 px-1 rounded">/widget.js</code> を読み込んでいます(<code class="bg-white/70 px-1 rounded">npm run dev</code> で起動して確認)</li>
</ul>
</div>
</main>
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
<p class="text-xs text-ads-dim text-center">音声図解コメントツール / ローカル動作確認用デモ</p>
</footer>
<!-- コメントウィジェット(ローカル開発サーバーの widget.js を読み込む) -->
<script src="/widget.js"></script>
</body>
</html>

2
public/widget.js Normal file

File diff suppressed because one or more lines are too long

53
scripts/migrate.ts Normal file
View File

@ -0,0 +1,53 @@
import { config } from 'dotenv';
config({ path: '.env.local' });
import { neon } from '@neondatabase/serverless';
async function migrate() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error('DATABASE_URL is not set. Add it to .env.local');
process.exit(1);
}
const sql = neon(url);
await sql`
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
author TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'comment',
quote TEXT NOT NULL DEFAULT '',
quote_context_before TEXT NOT NULL DEFAULT '',
quote_context_after TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'want',
parent_id TEXT,
resolved BOOLEAN NOT NULL DEFAULT false,
resolved_by TEXT,
resolved_at BIGINT,
timestamp BIGINT NOT NULL,
updated_at BIGINT,
page_url TEXT NOT NULL,
project_slug TEXT NOT NULL
)
`;
// ピンコメント用の座標列(既存テーブルにも安全に追加できるよう冪等)
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS pin_x DOUBLE PRECISION`;
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS pin_y DOUBLE PRECISION`;
// 要素アンカー方式(レスポンシブ追従): 最寄り要素のセレクタ+その要素からの相対オフセット
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_selector TEXT`;
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_dx DOUBLE PRECISION`;
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_dy DOUBLE PRECISION`;
await sql`CREATE INDEX IF NOT EXISTS idx_comments_project ON comments (project_slug)`;
await sql`CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_id)`;
console.log('Migration complete.');
}
migrate().catch((e) => {
console.error('Migration failed:', e);
process.exit(1);
});

View File

@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import type { Comment } from '@/shared/types';
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
}
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders() });
}
function json(data: unknown, status = 200) {
return NextResponse.json(data, { status, headers: corsHeaders() });
}
function verifyToken(request: NextRequest): boolean {
const token = process.env.API_TOKEN;
// ローカル開発ではトークン無しで動かせるようにする。本番で未設定なら、
// 設定ミスで API が誰でも書き込める状態になるより落ちた方が安全。
if (!token) return process.env.NODE_ENV !== 'production';
const auth = request.headers.get('Authorization');
if (!auth?.startsWith('Bearer ')) return false;
return auth.slice(7) === token;
}
export async function GET(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const slug = request.nextUrl.searchParams.get('slug');
if (!slug) return json({ error: 'slug is required' }, 400);
const sql = getDb();
const rows = await sql`
SELECT * FROM comments WHERE project_slug = ${slug} ORDER BY timestamp ASC
`;
return json(rows.map(toComment));
}
export async function POST(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const body = await request.json();
const { id, author, type, quote, quoteContext, content, priority, parentId, pageUrl, projectSlug, timestamp, pinX, pinY, anchorSelector, anchorDx, anchorDy } = body;
if (!id || !author || !projectSlug) return json({ error: 'Missing required fields' }, 400);
const sql = getDb();
await sql`
INSERT INTO comments (id, author, type, quote, quote_context_before, quote_context_after, content, priority, parent_id, page_url, project_slug, timestamp, pin_x, pin_y, anchor_selector, anchor_dx, anchor_dy)
VALUES (${id}, ${author}, ${type || 'comment'}, ${quote || ''}, ${quoteContext?.beforeText || ''}, ${quoteContext?.afterText || ''}, ${content || ''}, ${priority || 'want'}, ${parentId || null}, ${pageUrl || ''}, ${projectSlug}, ${timestamp || Date.now()}, ${pinX ?? null}, ${pinY ?? null}, ${anchorSelector ?? null}, ${anchorDx ?? null}, ${anchorDy ?? null})
`;
return json({ ok: true });
}
type PutAction = 'edit' | 'resolve' | 'cyclePriority' | 'rename' | 'move';
export async function PUT(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const body = await request.json();
const { id, action } = body as { id: string; action: PutAction; [key: string]: unknown };
if (!id) return json({ error: 'id is required' }, 400);
if (!action) return json({ error: 'action is required' }, 400);
const sql = getDb();
switch (action) {
case 'edit': {
const { content, priority } = body;
if (content === undefined || priority === undefined) return json({ error: 'content and priority are required for edit' }, 400);
await sql`
UPDATE comments SET content = ${content}, priority = ${priority}, updated_at = ${Date.now()} WHERE id = ${id}
`;
break;
}
case 'resolve': {
const { resolved, resolvedBy, resolvedAt } = body;
await sql`
UPDATE comments SET resolved = ${resolved}, resolved_by = ${resolvedBy || null}, resolved_at = ${resolvedAt || null} WHERE id = ${id}
`;
break;
}
case 'cyclePriority': {
const { priority } = body;
if (!priority) return json({ error: 'priority is required for cyclePriority' }, 400);
await sql`
UPDATE comments SET priority = ${priority} WHERE id = ${id}
`;
break;
}
case 'rename': {
const { author, oldAuthor, projectSlug } = body;
if (!author || !oldAuthor || !projectSlug) return json({ error: 'author, oldAuthor, projectSlug are required for rename' }, 400);
await sql`
UPDATE comments SET author = ${author} WHERE project_slug = ${projectSlug} AND author = ${oldAuthor}
`;
break;
}
case 'move': {
const { pinX, pinY, anchorSelector, anchorDx, anchorDy } = body;
if (pinX === undefined || pinY === undefined) return json({ error: 'pinX and pinY are required for move' }, 400);
await sql`
UPDATE comments SET pin_x = ${pinX}, pin_y = ${pinY}, anchor_selector = ${anchorSelector ?? null}, anchor_dx = ${anchorDx ?? null}, anchor_dy = ${anchorDy ?? null}, updated_at = ${Date.now()} WHERE id = ${id}
`;
break;
}
default:
return json({ error: `Unknown action: ${action}` }, 400);
}
return json({ ok: true });
}
export async function DELETE(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const id = request.nextUrl.searchParams.get('id');
if (!id) return json({ error: 'id is required' }, 400);
const sql = getDb();
await sql`DELETE FROM comments WHERE id = ${id} OR parent_id = ${id}`;
return json({ ok: true });
}
function toComment(row: Record<string, unknown>): Comment {
return {
id: row.id as string,
author: row.author as string,
type: (row.type as Comment['type']) || 'comment',
quote: (row.quote as string) || '',
quoteContext: {
beforeText: (row.quote_context_before as string) || '',
afterText: (row.quote_context_after as string) || '',
},
content: (row.content as string) || '',
priority: (row.priority as Comment['priority']) || 'want',
parentId: (row.parent_id as string) || null,
resolved: (row.resolved as boolean) || false,
resolvedBy: (row.resolved_by as string) || null,
resolvedAt: row.resolved_at ? Number(row.resolved_at) : null,
timestamp: Number(row.timestamp),
updatedAt: row.updated_at ? Number(row.updated_at) : null,
pageUrl: (row.page_url as string) || '',
pinX: row.pin_x != null ? Number(row.pin_x) : null,
pinY: row.pin_y != null ? Number(row.pin_y) : null,
anchorSelector: (row.anchor_selector as string) || null,
anchorDx: row.anchor_dx != null ? Number(row.anchor_dx) : null,
anchorDy: row.anchor_dy != null ? Number(row.anchor_dy) : null,
};
}

View File

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from 'next/server';
// 音声ファイルを base64 で受けるため body サイズ上限を緩める
export const maxDuration = 60;
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
}
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders() });
}
function json(data: unknown, status = 200) {
return NextResponse.json(data, { status, headers: corsHeaders() });
}
function verifyToken(request: NextRequest): boolean {
const token = process.env.API_TOKEN;
// ローカル開発ではトークン無しで動かせるようにする。本番で未設定なら、
// 設定ミスで Gemini を誰でも叩ける状態になるより落ちた方が安全。
if (!token) return process.env.NODE_ENV !== 'production';
const auth = request.headers.get('Authorization');
if (!auth?.startsWith('Bearer ')) return false;
return auth.slice(7) === token;
}
interface Target {
index: number;
text: string;
heading: string;
isHeading?: boolean;
}
// gemini-flash-latest は常に最新の flash 系モデルを指す公式エイリアス。
// 固定バージョン名(例: gemini-2.5-flashは新規APIキーで使えなくなることがある。
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-flash-latest';
// Gemini に JSON 配列で返させるためのスキーマ
const RESPONSE_SCHEMA = {
type: 'ARRAY',
items: {
type: 'OBJECT',
properties: {
target_index: { type: 'INTEGER' },
quote: { type: 'STRING' },
comment: { type: 'STRING' },
priority: { type: 'STRING', enum: ['must', 'better', 'want'] },
},
required: ['target_index', 'quote', 'comment', 'priority'],
},
};
function buildPrompt(targets: Target[]): string {
const list = targets
.map((t) => {
const context = t.isHeading ? '見出し' : t.heading || '(見出しなし)';
return `${t.index}\t[${context}]\t${t.text}`;
})
.join('\n');
return [
'あなたは図解レビューのフィードバック解析AIです。',
'ユーザーが音声で話したフィードバックを聞き取り、図解のどの部分に対する指摘かを1つずつ特定してください。',
'',
'# 図解の要素一覧',
'各行は「index<TAB>[分類]<TAB>その要素に表示されている文字」です。',
'[分類] が「見出し」の行はセクションの見出しそのもの、それ以外はその名前のセクションに属する本文・ラベルです。',
'ピンはあなたが選んだ要素の真上に立ちます。',
list,
'',
'# 指示',
'- 音声を聞き、指摘・要望・感想を1件ずつ抽出する',
'- quote には、その指摘の根拠になった発言を音声のまま短く書き出す',
'- target_index は、話者が実際に言及した語句と最も一致する要素を選ぶ',
'- 話者が具体的な文言(カードのラベル・数字・固有名詞など)を口にしたら、見出しではなくその文言そのものの要素を選ぶ',
'- 具体的な文言がなく話題だけ分かる場合は、そのセクションの見出し要素を選ぶ',
'- どの要素にも結び付けられない場合だけ target_index に -1 を入れる',
'- comment はユーザーの意図を簡潔にまとめた日本語にする',
'- priority は次の基準で割り当てる: 修正が必須=must / 改善してほしい=better / 軽微な要望や肯定的な発言=want',
'- 「ここはOK」「良い」などの肯定的な発言も want として記録する',
'- 指摘が複数あれば配列で複数返す。何も聞き取れなければ空配列を返す',
].join('\n');
}
export async function POST(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return json({ error: 'GEMINI_API_KEY が未設定です' }, 500);
}
let body: { audioBase64?: string; mimeType?: string; targets?: Target[] };
try {
body = await request.json();
} catch {
return json({ error: 'リクエストボディが不正です' }, 400);
}
const { audioBase64, mimeType, targets } = body;
if (!audioBase64) return json({ error: '音声データがありません' }, 400);
if (!Array.isArray(targets) || targets.length === 0) {
return json({ error: 'ページの要素情報がありません' }, 400);
}
// Gemini は codecs パラメータ付き mimeType を嫌うことがあるためベース型に正規化
const audioMime = (mimeType || 'audio/webm').split(';')[0];
const geminiBody = {
contents: [
{
parts: [
{ text: buildPrompt(targets) },
{ inlineData: { mimeType: audioMime, data: audioBase64 } },
],
},
],
generationConfig: {
responseMimeType: 'application/json',
responseSchema: RESPONSE_SCHEMA,
},
};
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
let geminiRes: Response;
try {
geminiRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(geminiBody),
});
} catch {
return json({ error: 'Gemini への接続に失敗しました' }, 502);
}
if (!geminiRes.ok) {
const detail = await geminiRes.text().catch(() => '');
return json({ error: `Gemini エラー (${geminiRes.status})`, detail }, 502);
}
const data = await geminiRes.json();
const text: string | undefined = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
return json({ error: 'Gemini から有効な応答が得られませんでした' }, 502);
}
let results: unknown;
try {
results = JSON.parse(text);
} catch {
return json({ error: 'Gemini 応答の JSON パースに失敗しました', detail: text }, 502);
}
return json({ results });
}

11
src/app/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}

7
src/lib/db.ts Normal file
View File

@ -0,0 +1,7 @@
import { neon } from '@neondatabase/serverless';
export function getDb() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
return neon(url);
}

5
src/shared/api-client.ts Normal file
View File

@ -0,0 +1,5 @@
export function authHeaders(token: string): Record<string, string> {
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) hdrs['Authorization'] = 'Bearer ' + token;
return hdrs;
}

61
src/shared/constants.ts Normal file
View File

@ -0,0 +1,61 @@
import type { Priority } from './types';
export const PRIORITY_COLORS: Record<
Priority,
{ bg: string; text: string; light: string; border: string }
> = {
must: {
bg: '#ef4444',
text: '#fff',
light: 'rgba(239,68,68,0.1)',
border: 'rgba(239,68,68,0.3)',
},
better: {
bg: '#f59e0b',
text: '#fff',
light: 'rgba(245,158,11,0.1)',
border: 'rgba(245,158,11,0.3)',
},
want: {
bg: '#22c55e',
text: '#fff',
light: 'rgba(34,197,94,0.1)',
border: 'rgba(34,197,94,0.3)',
},
};
export const PRIORITY_LABELS: Record<Priority, string> = {
must: 'Must',
better: 'Better',
want: 'Want',
};
export const PRIORITY_CYCLE: Record<Priority, Priority> = {
must: 'better',
better: 'want',
want: 'must',
};
export const HIGHLIGHT_COLORS: Record<
Priority,
{ bg: string; hoverBg: string; border: string }
> = {
must: {
bg: 'rgba(239,68,68,0.15)',
hoverBg: 'rgba(239,68,68,0.25)',
border: '#ef4444',
},
better: {
bg: 'rgba(245,158,11,0.15)',
hoverBg: 'rgba(245,158,11,0.25)',
border: '#f59e0b',
},
want: {
bg: 'rgba(34,197,94,0.15)',
hoverBg: 'rgba(34,197,94,0.25)',
border: '#22c55e',
},
};
export const USERNAME_KEY = 'fb-username';
export const SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';

10
src/shared/slug.ts Normal file
View File

@ -0,0 +1,10 @@
export function slugify(url: string): string {
return url
.replace(/^https?:\/\//, '')
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
.substring(0, 100);
}
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}

12
src/shared/time.ts Normal file
View File

@ -0,0 +1,12 @@
export function fmtTime(ts: number): string {
const d = Date.now() - ts;
if (d < 60000) return 'たった今';
if (d < 3600000) return Math.floor(d / 60000) + '分前';
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
return new Date(ts).toLocaleDateString('ja-JP', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

27
src/shared/types.ts Normal file
View File

@ -0,0 +1,27 @@
export type Priority = 'must' | 'better' | 'want';
export type CommentType = 'comment' | 'strikethrough' | 'pin';
export interface Comment {
id: string;
author: string;
type: CommentType;
quote: string;
quoteContext: { beforeText: string; afterText: string };
content: string;
priority: Priority;
parentId: string | null;
resolved: boolean;
resolvedBy: string | null;
resolvedAt: number | null;
timestamp: number;
updatedAt: number | null;
pageUrl: string;
pinX: number | null; // type==='pin' のときコンテナ幅に対する%(フォールバック)
pinY: number | null; // type==='pin' のときコンテナ上端からのpxフォールバック
anchorSelector: string | null; // 追従の基準にする最寄り要素の CSS セレクタ
anchorDx: number | null; // 基準要素の左上からの相対px
anchorDy: number | null; // 基準要素の左上からの相対px
}
export type FilterMode = 'unresolved' | 'resolved' | 'all';

53
src/widget/api.ts Normal file
View File

@ -0,0 +1,53 @@
import { authHeaders } from '../shared/api-client';
let apiBase = '';
let apiToken = '';
export function initApi(base: string, token: string): void {
apiBase = base;
apiToken = token;
}
export function api(method: string, params: Record<string, unknown>): Promise<unknown> {
let url = apiBase + '/api/comments';
const hdrs = authHeaders(apiToken);
const opts: RequestInit = { method, headers: hdrs };
if (method === 'GET') {
url += '?slug=' + encodeURIComponent(params.slug as string);
} else if (method === 'DELETE') {
url += '?id=' + encodeURIComponent(params.id as string);
} else {
opts.body = JSON.stringify(params);
}
return fetch(url, opts).then((r) => r.json());
}
export interface VoiceTargetInput {
index: number;
text: string; // その要素に表示されている文字
heading: string; // 直前の見出し(どのセクションの中か)
isHeading: boolean; // 見出し要素そのものか
}
export interface VoiceResult {
target_index: number; // 一致する要素がなければ -1
quote: string; // 判断の根拠になった発言
comment: string;
priority: string;
}
/**
* (base64) + /api/voice-analyze
* Gemini
*/
export function voiceApi(
audioBase64: string,
mimeType: string,
targets: VoiceTargetInput[],
): Promise<{ results?: VoiceResult[]; error?: string }> {
return fetch(apiBase + '/api/voice-analyze', {
method: 'POST',
headers: authHeaders(apiToken),
body: JSON.stringify({ audioBase64, mimeType, targets }),
}).then((r) => r.json());
}

24
src/widget/dom.ts Normal file
View File

@ -0,0 +1,24 @@
type Attrs = Record<string, unknown>;
export function el(tag: string, attrs?: Attrs | null, children?: string | Node | (Node | null)[] | null): HTMLElement {
const e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach((k) => {
const v = attrs[k];
if (k === 'className') e.className = v as string;
else if (k === 'innerHTML') e.innerHTML = v as string;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.substring(2).toLowerCase(), v as EventListener);
else e.setAttribute(k, String(v));
});
if (children != null) {
if (typeof children === 'string') e.textContent = children;
else if (Array.isArray(children)) children.forEach((c) => { if (c) e.appendChild(c); });
else e.appendChild(children);
}
return e;
}
export function esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}

110
src/widget/highlight.ts Normal file
View File

@ -0,0 +1,110 @@
import { state, type FbComment } from './state';
function collectTextNodes(): Text[] {
const nodes: Text[] = [];
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
const p = n.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let n: Node | null;
while ((n = tw.nextNode())) nodes.push(n as Text);
return nodes;
}
export function mapNormToOrig(orig: string, normStart: number, normEnd: number): [number, number] | null {
const normalized = orig.replace(/[\s\u00A0]+/g, ' ');
if (normStart >= normalized.length) return null;
let origIdx = 0, normIdx = 0;
let origStart = -1, origEnd = -1;
while (origIdx < orig.length) {
if (normIdx === normStart && origStart === -1) origStart = origIdx;
if (normIdx === normEnd) { origEnd = origIdx; break; }
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
origIdx++; normIdx++;
} else {
origIdx++;
}
}
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
return [origStart, origEnd];
}
function wrapTextRange(node: Text, start: number, end: number, comment: FbComment, onClickHighlight: (id: string) => void): void {
const orig = node.textContent!;
const before = document.createTextNode(orig.substring(0, start));
const mark = document.createElement('mark');
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
mark.dataset.commentId = comment.id;
mark.textContent = orig.substring(start, end);
mark.addEventListener('click', () => { onClickHighlight(comment.id); });
const after = document.createTextNode(orig.substring(end));
node.parentNode!.insertBefore(before, node);
node.parentNode!.insertBefore(mark, node);
node.parentNode!.insertBefore(after, node);
node.parentNode!.removeChild(node);
}
export function applyHighlights(onClickHighlight: (id: string) => void): void {
document.querySelectorAll('.fb-highlight').forEach((el) => {
const t = document.createTextNode(el.textContent || '');
el.parentNode!.replaceChild(t, el);
});
document.body.normalize();
state.comments.filter((c) => !c.parentId && !c.resolved && c.quote && c.quote.length >= 2).forEach((c) => {
const search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
const textNodes = collectTextNodes();
let found = false;
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const orig = node.textContent!;
const di = orig.indexOf(search);
if (di !== -1) {
try { wrapTextRange(node, di, di + search.length, c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
break;
}
const norm = orig.replace(/[\s\u00A0]+/g, ' ');
const ni = norm.indexOf(search);
if (ni === -1) continue;
const range = mapNormToOrig(orig, ni, ni + search.length);
if (range) {
try { wrapTextRange(node, range[0], range[1], c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
break;
}
}
if (!found) {
let concat = '';
const nodeMap: Array<{ node: Text; start: number; end: number }> = [];
for (let j = 0; j < textNodes.length; j++) {
const s = concat.length;
concat += textNodes[j].textContent;
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
}
const concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
const ci = concatNorm.indexOf(search);
if (ci !== -1) {
const range = mapNormToOrig(concat, ci, ci + search.length);
if (range) {
const mStart = range[0], mEnd = range[1];
for (let k = nodeMap.length - 1; k >= 0; k--) {
const nm = nodeMap[k];
if (nm.end <= mStart || nm.start >= mEnd) continue;
const ls = Math.max(0, mStart - nm.start);
const le = Math.min(nm.node.textContent!.length, mEnd - nm.start);
try { wrapTextRange(nm.node, ls, le, c, onClickHighlight); } catch (_) { /* ignore */ }
}
}
}
}
});
}

16
src/widget/icons.ts Normal file
View File

@ -0,0 +1,16 @@
export const SVG: Record<string, string> = {
message: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/></svg>',
check: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
rotateCcw: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
pencil: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>',
trash: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
user: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
x: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
panelRight: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
mic: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>',
loader: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>',
};
export function icon(name: string, size = 14, strokeWidth = 2): string {
return (SVG[name] || '').replace(/SIZE/g, String(size)).replace(/SW/g, String(strokeWidth));
}

286
src/widget/index.ts Normal file
View File

@ -0,0 +1,286 @@
/**
* Icons: Lucide (https://lucide.dev)
* ISC License - Copyright (c) Lucide Contributors 2026
*/
// document.currentScript は IIFE 先頭でキャプチャ必須esbuild の import 巻き上げ前に実行される)
const SCRIPT = document.currentScript as HTMLScriptElement | null;
const API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
const API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
import type { Priority } from '../shared/types';
import { USERNAME_KEY, PRIORITY_CYCLE } from '../shared/constants';
import { generateId } from '../shared/slug';
import { initApi, api } from './api';
import { state, slug, type FbComment } from './state';
import { injectStyles } from './styles';
import { render, toggleSidebar, setRenderDeps } from './render/index';
import { setToggleAreaHandler, setToggleVoiceHandler } from './render/toggle';
import { setSidebarActions } from './render/sidebar';
import { applyHighlights } from './highlight';
import { scrollToQuote, scrollToCard, scrollToPin } from './scroll';
import { setupTextSelection } from './selection';
import {
enterPinMode, exitPinMode,
handlePinClick, renderPins, renderPinPopup, cancelPinPopup,
setupPinDrag,
} from './pin';
import { toggleRecording, setVoiceOnChange } from './voice';
initApi(API_BASE, API_TOKEN);
function loadComments(): Promise<void> {
return api('GET', { slug }).then((c) => {
if (Array.isArray(c)) state.comments = c;
render();
renderPins(); // ピンも state.comments に含まれるため取得後に描画
applyHighlights(onClickHighlight);
}).catch(() => {
render();
renderPins();
applyHighlights(onClickHighlight);
});
}
function closePopup(): void {
state.selectedText = '';
state.selectedRect = null;
state.popupContent = '';
state.popupPriority = 'must';
render();
}
function submitComment(priority: Priority): void {
if (!state.selectedText) return;
const c: FbComment = {
id: generateId(), author: state.username || '匿名', type: 'comment',
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
content: state.popupContent.trim(),
priority, parentId: null, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
};
state.comments.push(c);
closePopup();
applyHighlights(onClickHighlight);
api('POST', c as unknown as Record<string, unknown>).then(loadComments);
}
function resolveComment(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c) return;
const now = !c.resolved;
c.resolved = now;
c.resolvedBy = now ? state.username : null;
c.resolvedAt = now ? Date.now() : null;
render(); renderPins(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'resolve', resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
}
function cyclePriority(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c || c.author !== state.username) return;
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
render(); renderPins(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'cyclePriority', priority: c.priority });
}
function deleteComment(id: string): void {
state.comments = state.comments.filter((c) => c.id !== id && c.parentId !== id);
render(); renderPins(); applyHighlights(onClickHighlight);
api('DELETE', { id });
}
function deleteReply(id: string): void {
state.comments = state.comments.filter((c) => c.id !== id);
render();
api('DELETE', { id });
}
function saveEdit(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c) return;
c.content = state.editContent;
c.priority = state.editPriority;
state.editingId = null;
render(); renderPins(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'edit', content: c.content, priority: c.priority });
}
function submitReply(parentId: string): void {
if (!state.replyText.trim() || !state.username) return;
const r: FbComment = {
id: generateId(), author: state.username, type: 'comment',
quote: '', quoteContext: { beforeText: '', afterText: '' },
content: state.replyText.trim(), priority: 'want',
parentId, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
};
state.comments.push(r);
state.replyingTo = null;
state.replyText = '';
render();
api('POST', r as unknown as Record<string, unknown>);
}
function finishNameEdit(): void {
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
const oldName = state.username;
state.username = state.nameInput.trim();
localStorage.setItem(USERNAME_KEY, state.username);
api('PUT', { id: '_rename', action: 'rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
}
state.editingName = false;
render();
}
function onClickHighlight(id: string): void {
scrollToCard(id, toggleSidebar);
}
function togglePinMode(): void {
if (state.pinMode) {
exitPinMode();
} else {
enterPinMode();
}
render();
renderPins(); // 吹き出しの編集UIバッジ/削除)の表示をモードに合わせて更新
}
function handleVoiceToggle(): void {
toggleRecording();
}
let voiceToastTimer: ReturnType<typeof setTimeout> | null = null;
/** 音声解析の失敗を画面に出す。console だけだと気づけず、原因不明のまま使い続けてしまう。 */
function showVoiceToast(message: string): void {
let toast = document.getElementById('fb-voice-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'fb-voice-toast';
toast.className = 'fb-voice-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
// サイドバーが開いているとその上に重なって読めないので、開いている分だけ左に寄せる
toast.style.right = (state.sidebarOpen ? state.sidebarWidth + 16 : 16) + 'px';
// 追加直後に show を付けるとトランジションが走らないため 1 フレーム待つ
requestAnimationFrame(() => toast.classList.add('show'));
if (voiceToastTimer) clearTimeout(voiceToastTimer);
voiceToastTimer = setTimeout(() => toast.classList.remove('show'), 6000);
}
// Wire up dependencies before first render
setRenderDeps(closePopup, submitComment);
setToggleAreaHandler(togglePinMode);
setToggleVoiceHandler(handleVoiceToggle);
// 録音状態の変化やエラー、ピン配置完了時に UI を更新する
setVoiceOnChange(() => {
render();
if (state.voiceError) {
showVoiceToast(state.voiceError);
state.voiceError = null; // 一度知らせたら消す(次の録音に持ち越さない)
}
});
setSidebarActions({
toggleSidebar,
cyclePriority,
scrollToQuote,
scrollToPin,
resolveComment,
deleteComment,
deleteReply,
saveEdit,
submitReply,
finishNameEdit,
});
// フィードバックモード ON 中の「クリック=ピン / ドラッグ=テキスト選択」自動判定。
// クリックかドラッグかは mousedown→click の移動距離で判定し、テキスト選択時はピンを置かない。
const WIDGET_UI_SELECTOR = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip,.fb-pin-delete,.fb-popup';
const CLICK_DRAG_THRESHOLD = 6; // px
function closePinPopup(): void {
cancelPinPopup();
state.editingPinId = null;
const popup = document.getElementById('fb-pin-popup');
if (popup) popup.classList.remove('show');
render();
}
function setupClickAndDrag(): void {
let downX = 0;
let downY = 0;
document.addEventListener('mousedown', (e) => {
downX = e.clientX;
downY = e.clientY;
}, true);
document.addEventListener('click', (e) => {
const inWidgetUi = !!(e.target as HTMLElement).closest(WIDGET_UI_SELECTOR);
if (!state.pinMode) {
// モードOFFでもピンをクリックすれば編集ポップアップが開く。
// モードON時のように「外側クリック別の場所にピンを追加」へは
// 遷移しないので、ここで閉じないと閉じ手段が Escape だけになる。
if (state.pinPopupPos && !inWidgetUi) closePinPopup();
return;
}
if (inWidgetUi) return;
// ドラッグでテキスト選択された場合はテキストフロー(mode1)に委譲
const sel = window.getSelection();
if (sel && sel.toString().trim()) return;
// 移動が大きければドラッグとみなしてピンは置かない
if (Math.hypot(e.clientX - downX, e.clientY - downY) >= CLICK_DRAG_THRESHOLD) return;
e.preventDefault();
e.stopPropagation();
handlePinClick(e);
if (state.pinPopupPos) {
renderPinPopup(render);
}
render();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (state.pinPopupPos) {
closePinPopup();
} else if (state.pinMode) {
exitPinMode();
render();
renderPins();
}
}
});
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
function setupPinReposition(): void {
// 画面幅が変わるとアンカー基準位置も変わるため、ピンを再配置する(追従)
window.addEventListener('resize', () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { renderPins(); }, 100);
});
}
function init(): void {
injectStyles();
render();
setupTextSelection(render, closePopup);
setupClickAndDrag();
setupPinDrag(render);
setupPinReposition();
loadComments(); // 取得後に renderPins も実行される(ピンは state.comments に含まれる)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

513
src/widget/pin.ts Normal file
View File

@ -0,0 +1,513 @@
import type { Priority } from '../shared/types';
import { PRIORITY_COLORS } from '../shared/constants';
import { generateId } from '../shared/slug';
import { state, slug, type FbComment } from './state';
import { api } from './api';
import { esc } from './dom';
function getContainer(): HTMLElement {
return document.querySelector('main') || document.body;
}
/**
*
* dx/dy px
* dx/dy null
* 使pxで持つと
*
*/
export interface PinAnchor { selector: string; dx: number | null; dy: number | null; }
// 新規ピン配置時に handlePinClick が捕捉したアンカーを submit まで一時保持する
let pendingAnchor: PinAnchor | null = null;
/** 要素から、document.querySelector で再取得できる安定的な CSS パスを生成する。 */
function cssPath(el: Element): string {
const esc = (s: string) => (window.CSS && CSS.escape ? CSS.escape(s) : s);
const parts: string[] = [];
let node: Element | null = el;
while (node && node.nodeType === 1 && node !== document.body) {
if ((node as HTMLElement).id) { parts.unshift('#' + esc((node as HTMLElement).id)); break; }
let sel = node.tagName.toLowerCase();
const parent: Element | null = node.parentElement;
if (parent) {
const sib = Array.from(parent.children).filter((c) => c.tagName === node!.tagName);
if (sib.length > 1) sel += ':nth-of-type(' + (sib.indexOf(node) + 1) + ')';
}
parts.unshift(sel);
node = parent;
}
return parts.join('>');
}
const WIDGET_UI = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip';
/**
* (clientX,clientY)
* elementFromPoint
*
*
*/
function captureAnchorAtPoint(clientX: number, clientY: number): PinAnchor | null {
const container = getContainer();
const stack = document.elementsFromPoint(clientX, clientY) as HTMLElement[];
const el = stack.find(
(e) => !e.closest(WIDGET_UI) && e !== container && e !== document.body && container.contains(e),
);
if (!el) return null;
const eRect = el.getBoundingClientRect();
return { selector: cssPath(el), dx: clientX - eRect.left, dy: clientY - eRect.top };
}
/** その座標がサイドバー・トグル・ポップアップに覆われているか。 */
function droppedOnWidgetUi(clientX: number, clientY: number): boolean {
return document
.elementsFromPoint(clientX, clientY)
.some((e) => (e as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-pin-popup'));
}
/**
*
* 使 box
* Range
*
*/
function textRect(el: Element): DOMRect {
try {
const r = document.createRange();
r.selectNodeContents(el);
const box = r.getBoundingClientRect();
if (box.width >= 1 && box.height >= 1) return box;
} catch {
// Range が張れない要素(置換要素など)は要素矩形にフォールバック
}
return el.getBoundingClientRect();
}
/**
*
* transform:translate(-50%,-100%)
*
*/
function textAnchorPoint(el: Element): { x: number; y: number } {
const t = textRect(el);
return { x: t.left + t.width / 2, y: t.top };
}
/** 要素の左上から dx/dy だけずらした点(手動ピン:クリックした一点を保つ)。 */
function offsetPoint(el: Element, dx: number, dy: number): { x: number; y: number } {
const e = el.getBoundingClientRect();
return { x: e.left + dx, y: e.top + dy };
}
/**
*
* resolvePinPosition
*/
export function anchorFromElement(el: Element): PinAnchor {
return { selector: cssPath(el), dx: null, dy: null };
}
/**
*
* anchorSelector +
* pinX%/pinY px
*/
function resolvePinPosition(c: { pinX?: number | null; pinY?: number | null; anchorSelector?: string | null; anchorDx?: number | null; anchorDy?: number | null; }): { xPct: number; yPx: number } {
const container = getContainer();
const cRect = container.getBoundingClientRect();
if (c.anchorSelector) {
let el: Element | null = null;
try { el = document.querySelector(c.anchorSelector); } catch { el = null; }
if (el) {
// オフセット無し = 要素の文字を指すピン。現在のレイアウトで測り直すので
// 画面幅が変わって折り返しが変化しても文字に追従する。
const base = c.anchorDx == null || c.anchorDy == null
? textAnchorPoint(el)
: offsetPoint(el, c.anchorDx, c.anchorDy);
let xPct = ((base.x - cRect.left) / cRect.width) * 100;
if (xPct < 5) xPct = 5;
if (xPct > 95) xPct = 95;
return { xPct, yPx: base.y - cRect.top };
}
}
// コンテナの外(余白)をクリックして作られたピンは pinX が範囲外になりうる。
// 画面幅が狭いとそのまま画面外に出て触れなくなるので、ここでも丸めておく。
let xPct = c.pinX ?? 50;
if (xPct < 5) xPct = 5;
if (xPct > 95) xPct = 95;
return { xPct, yPx: c.pinY ?? 0 };
}
/** state.comments の中からピンtype==='pin' のトップレベル)を作成順で返す。 */
export function getPins(): FbComment[] {
return state.comments.filter((c) => c.type === 'pin' && !c.parentId);
}
/** ピンの通し番号(マーカーとサイドバーカードで一致させる。作成順=配列順)。 */
export function pinNumber(id: string): number {
return getPins().findIndex((p) => p.id === id) + 1;
}
/**
* x: コンテナ幅に対する %, y: コンテナ上端からの px
* handlePinClick (voice.ts)
* anchorFromElement
*/
export function elementToPinCoords(target: Element): { x: number; y: number } {
const container = getContainer();
const cRect = container.getBoundingClientRect();
const p = textAnchorPoint(target);
let xPct = ((p.x - cRect.left) / cRect.width) * 100;
if (xPct < 5) xPct = 5; // 左端に寄りすぎるとピンが見切れる
if (xPct > 95) xPct = 95;
return { x: xPct, y: p.y - cRect.top }; // y はスクロールに依存しないコンテナ内オフセット
}
export function enterPinMode(): void {
state.pinMode = true;
state.pinPopupPos = null;
document.body.classList.add('fb-pin-mode');
}
export function exitPinMode(): void {
state.pinMode = false;
state.pinPopupPos = null;
document.body.classList.remove('fb-pin-mode');
}
export function handlePinClick(e: MouseEvent): void {
if ((e.target as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip')) return;
const container = getContainer();
const containerRect = container.getBoundingClientRect();
const relX = e.clientX - containerRect.left;
const xPct = (relX / containerRect.width) * 100;
const yPx = e.pageY - (containerRect.top + window.scrollY);
state.editingPinId = null; // 新規ピン追加(編集モードを解除)
state.pinPopupPos = { x: xPct, y: yPx };
pendingAnchor = captureAnchorAtPoint(e.clientX, e.clientY); // 最寄り要素を追従基準に
}
/**
* 1
* type='pin' state.comments DB
* renderPins
*/
export function addPin(x: number, y: number, content: string, priority: Priority, anchor?: PinAnchor | null): FbComment {
const c: FbComment = {
id: generateId(),
author: state.username || '匿名',
type: 'pin',
quote: '',
quoteContext: { beforeText: '', afterText: '' },
content: content.trim() || '(コメントなし)',
priority,
parentId: null,
pageUrl: window.location.href,
projectSlug: slug,
timestamp: Date.now(),
resolved: false,
resolvedBy: null,
resolvedAt: null,
updatedAt: null,
pinX: x,
pinY: y,
anchorSelector: anchor?.selector ?? null,
anchorDx: anchor?.dx ?? null,
anchorDy: anchor?.dy ?? null,
};
state.comments.push(c);
api('POST', c as unknown as Record<string, unknown>);
return c;
}
export function submitPinComment(content: string, priority: Priority): void {
if (!state.pinPopupPos) return;
addPin(state.pinPopupPos.x, state.pinPopupPos.y, content, priority, pendingAnchor);
pendingAnchor = null;
state.pinPopupPos = null;
renderPins();
}
export function cancelPinPopup(): void {
state.pinPopupPos = null;
}
/** ピンと返信を削除。DBにも反映。 */
export function deletePin(id: string): void {
state.comments = state.comments.filter((c) => c.id !== id && c.parentId !== id);
api('DELETE', { id });
renderPins();
}
/** ピンの位置を更新(ドラッグ移動)。アンカーも取り直して DB に反映。 */
export function movePin(id: string, x: number, y: number, anchor?: PinAnchor | null): void {
const c = state.comments.find((p) => p.id === id);
if (!c) return;
c.pinX = x;
c.pinY = y;
c.anchorSelector = anchor?.selector ?? null;
c.anchorDx = anchor?.dx ?? null;
c.anchorDy = anchor?.dy ?? null;
api('PUT', { id, action: 'move', pinX: x, pinY: y, anchorSelector: c.anchorSelector, anchorDx: c.anchorDx, anchorDy: c.anchorDy });
renderPins();
}
/** ピンの本文・優先度を更新編集ポップアップの保存。DBにも反映。 */
export function updatePin(id: string, content: string, priority: Priority): void {
const c = state.comments.find((p) => p.id === id);
if (!c) return;
c.content = content.trim() || '(コメントなし)';
c.priority = priority;
api('PUT', { id, action: 'edit', content: c.content, priority });
renderPins();
}
/** ピンをクリックしたとき、その場に編集ポップアップ(本文・優先度・削除)を開く。 */
export function openPinEditor(id: string, onRender: () => void): void {
const c = getPins().find((p) => p.id === id);
if (!c) return;
state.editingPinId = id;
const pos = resolvePinPosition(c); // アンカーで再計算した現在位置にポップアップを出す
state.pinPopupPos = { x: pos.xPct, y: pos.yPx };
renderPinPopup(onRender);
}
/**
*
* sidebar mousedownmousemovemouseup
*
* ON
*
*/
export function setupPinDrag(onRender: () => void): void {
document.addEventListener('mousedown', (e) => {
const target = e.target as HTMLElement;
const marker = target.closest('.fb-pin-marker') as HTMLElement | null;
if (!marker) return;
const id = marker.dataset.pinId;
if (!id) return;
const startX = e.clientX;
const startY = e.clientY;
let moved = false;
function onMove(ev: MouseEvent) {
if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) < 6) return;
if (!moved) {
moved = true;
document.body.classList.add('fb-dragging-pin');
}
ev.preventDefault();
const container = getContainer();
const cRect = container.getBoundingClientRect();
let xPct = ((ev.clientX - cRect.left) / cRect.width) * 100;
if (xPct < 5) xPct = 5;
if (xPct > 95) xPct = 95;
marker!.style.left = xPct + '%';
marker!.style.top = (ev.clientY - cRect.top) + 'px';
}
function cleanup() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
window.removeEventListener('blur', onCancel);
document.body.classList.remove('fb-dragging-pin');
}
// ウィンドウ外でボタンを離すなどして mouseup を取り逃すと、body に
// user-select:none が残ってページ全体が選択できなくなる。
function onCancel() {
const wasMoving = moved;
cleanup();
if (wasMoving) renderPins(); // 保存せず元の位置に戻す
}
function onUp(ev: MouseEvent) {
const wasMoving = moved;
cleanup();
if (!wasMoving) {
openPinEditor(id!, onRender); // 動かさずに離した = クリック → 編集
return;
}
// サイドバーやトグルの上で離した場合は移動をキャンセルして元に戻す。
// その座標にピンは置けないので、端に丸めて別の場所へ飛ばすより予測しやすい。
if (droppedOnWidgetUi(ev.clientX, ev.clientY)) {
renderPins();
return;
}
const container = getContainer();
const cRect = container.getBoundingClientRect();
let xPct = ((ev.clientX - cRect.left) / cRect.width) * 100;
if (xPct < 5) xPct = 5;
if (xPct > 95) xPct = 95;
const yPx = ev.clientY - cRect.top;
const anchor = captureAnchorAtPoint(ev.clientX, ev.clientY); // 落とした先の要素を新しい追従基準に
movePin(id!, xPct, yPx, anchor);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
window.addEventListener('blur', onCancel);
});
}
export function renderPins(): void {
document.querySelectorAll('.fb-pin-marker').forEach((el) => el.remove());
const container = getContainer();
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
// まず各ピンの表示座標を解決(アンカー追従。無ければ pinX/pinY フォールバック)
const pins = getPins();
const pos = pins.map((p) => resolvePinPosition(p));
// 近接ピンをクラスタ化し、重なって見えないよう横に扇状オフセットする(データは変更しない)。
const FAN_STEP = 26; // px
const FAN_EDGE = 16; // px。マーカーの半径ぶんコンテナの内側に留める
const cWidth = container.getBoundingClientRect().width;
const clusters: { x: number; y: number; items: number[] }[] = [];
pos.forEach((p, i) => {
let cl = clusters.find((c) => Math.abs(c.x - p.xPct) < 2 && Math.abs(c.y - p.yPx) < 24);
if (!cl) { cl = { x: p.xPct, y: p.yPx, items: [] }; clusters.push(cl); }
cl.items.push(i);
});
const fanOffset: Record<number, number> = {};
clusters.forEach((c) => {
const n = c.items.length;
c.items.forEach((idx, k) => { fanOffset[idx] = (k - (n - 1) / 2) * FAN_STEP; });
});
pins.forEach((comment, idx) => {
const pin = document.createElement('div');
pin.className = 'fb-pin-marker' + (comment.resolved ? ' resolved' : '');
pin.dataset.pinId = comment.id;
const pc = PRIORITY_COLORS[comment.priority] || PRIORITY_COLORS.want;
const xPct = pos[idx].xPct;
// 扇状オフセットでコンテナの外にはみ出すと、狭い画面ではピンが画面外に出て
// 触れなくなる。はみ出す分は内側に押し戻す。
const basePx = (xPct / 100) * cWidth;
const spread = Math.min(Math.max(basePx + (fanOffset[idx] || 0), FAN_EDGE), cWidth - FAN_EDGE);
const off = Math.round(spread - basePx);
// 横オフセットは calc で left に加算transform は維持されるのでホバー拡大も効く)
pin.style.left = off ? 'calc(' + xPct + '% + ' + off + 'px)' : xPct + '%';
pin.style.top = pos[idx].yPx + 'px';
pin.style.setProperty('--pin-color', pc.bg);
pin.innerHTML = '<div class="fb-pin-icon" style="background:' + pc.bg + '">' + (idx + 1) + '</div>';
// ホバー吹き出しは「内容の確認専用」。編集・削除・優先度はピンをクリックして開く編集ポップアップで。
const tooltip = document.createElement('div');
tooltip.className = 'fb-pin-tooltip';
const priLabel = comment.priority.charAt(0).toUpperCase() + comment.priority.slice(1);
const badge = '<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:700;color:#fff;background:' + pc.bg + '">' + priLabel + '</span>';
const hint = '<div style="font-size:11px;color:#a3a3a3;margin-top:6px">クリックで編集 / ドラッグで移動</div>';
tooltip.innerHTML = '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">' + badge + '<span style="font-size:11px;color:#737373">' + esc(comment.author) + '</span></div><div style="font-size:13px;color:#0a0a0a;white-space:pre-wrap">' + esc(comment.content) + '</div>' + hint;
pin.appendChild(tooltip);
container.appendChild(pin);
});
}
export function renderPinPopup(onRender: () => void): void {
let popup = document.getElementById('fb-pin-popup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'fb-pin-popup';
popup.className = 'fb-pin-popup';
document.body.appendChild(popup);
}
if (!state.pinPopupPos) {
popup.classList.remove('show');
return;
}
popup.classList.add('show');
// 編集対象ピン(あれば編集モード、なければ新規追加モード)
const editing = state.editingPinId
? getPins().find((p) => p.id === state.editingPinId) || null
: null;
const container = getContainer();
const containerRect = container.getBoundingClientRect();
const absX = (state.pinPopupPos.x / 100) * containerRect.width + containerRect.left;
const absY = state.pinPopupPos.y + containerRect.top + window.scrollY;
let h = '<div class="fb-pin-popup-head">' + (editing ? 'コメントを編集' : 'コメントを追加') + '</div>';
h += '<textarea id="fb-pin-textarea" placeholder="コメントを入力...(優先度を押すと' + (editing ? '保存' : '送信') + '"></textarea>';
h += '<div class="fb-pin-popup-pri">';
(['must', 'better', 'want'] as const).forEach((p) => {
const pc = PRIORITY_COLORS[p];
h += '<button class="fb-pin-pri-btn" data-pri="' + p + '" style="background:' + pc.bg + ';color:#fff;border:none">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
// 送信/キャンセルは撤去優先度ボタンで送信、ESC/外側クリックでキャンセル)。編集時のみ削除ボタンを残す。
if (editing) {
h += '<div class="fb-pin-popup-actions"><button class="fb-pin-delete-btn">削除</button></div>';
}
popup.innerHTML = h;
const pw = 300, m = 8;
let top = absY + 24 - window.scrollY;
if (top + 220 > window.innerHeight) top = absY - 220 - m - window.scrollY;
let left = absX - pw / 2;
if (left < m) left = m;
if (left + pw > window.innerWidth - m) left = window.innerWidth - pw - m;
popup.style.position = 'fixed';
popup.style.top = top + 'px';
popup.style.left = left + 'px';
const textarea = popup.querySelector('#fb-pin-textarea') as HTMLTextAreaElement;
textarea.value = editing ? editing.content : '';
setTimeout(() => textarea?.focus(), 50);
const priBtns = popup.querySelectorAll('.fb-pin-pri-btn');
let selectedPri: Priority = editing ? editing.priority : 'better';
const paintPri = () => priBtns.forEach((b) => {
(b as HTMLElement).style.opacity = (b as HTMLElement).dataset.pri === selectedPri ? '1' : '0.4';
});
priBtns.forEach((btn) => {
btn.addEventListener('click', () => {
selectedPri = (btn as HTMLElement).dataset.pri as Priority;
commit(); // 優先度を押した時点で即送信/保存(ワンクリック)
});
});
paintPri();
const close = () => {
state.editingPinId = null;
state.pinPopupPos = null;
popup!.classList.remove('show');
onRender();
};
const commit = () => {
if (editing) {
updatePin(editing.id, textarea.value, selectedPri);
} else {
submitPinComment(textarea.value, selectedPri);
}
close();
};
if (editing) {
popup.querySelector('.fb-pin-delete-btn')!.addEventListener('click', () => {
deletePin(editing.id);
close();
});
}
textarea.addEventListener('keydown', (ev) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') commit();
});
}

83
src/widget/render/card.ts Normal file
View File

@ -0,0 +1,83 @@
import { PRIORITY_COLORS } from '../../shared/constants';
import { fmtTime } from '../../shared/time';
import { esc } from '../dom';
import { icon } from '../icons';
import { state, type FbComment } from '../state';
import { pinNumber } from '../pin';
export function renderCard(c: FbComment): string {
const isOwn = c.author === state.username;
const pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
let h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
h += '<div class="fb-card-head"><div class="fb-card-head-left">';
h += '<div class="fb-avatar">' + esc(c.author.charAt(0)) + '</div>';
h += '<span class="fb-author">' + esc(c.author) + '</span>';
h += '<span class="fb-time">' + fmtTime(c.timestamp) + '</span>';
if (c.resolved) h += '<span class="fb-resolved-mark">' + icon('check', 12) + ' 解決済</span>';
h += '</div>';
h += '<span class="fb-badge-p' + (isOwn ? ' own' : '') + '" style="background:' + pc.bg + '" data-action="cycle" data-id="' + c.id + '">' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + '</span>';
h += '</div>';
if (c.type === 'pin') {
// ピンは引用の代わりに「📍 #N」インジケータクリックで該当ピンへスクロール
h += '<div class="fb-pin-ref" data-action="scroll-pin" data-id="' + c.id + '"><span class="fb-pin-ref-badge" style="background:' + pc.bg + '">' + pinNumber(c.id) + '</span>ピンを表示</div>';
} else if (c.quote) {
const q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote;
h += '<div class="fb-quote" style="border-left-color:' + pc.bg + '" data-action="scroll-quote" data-id="' + c.id + '">' + esc(q) + '</div>';
}
if (state.editingId === c.id) {
h += '<div class="fb-edit-area">';
h += '<div class="fb-edit-pri">';
(['must', 'better', 'want'] as const).forEach((p) => {
const sel = state.editPriority === p;
const pc2 = PRIORITY_COLORS[p];
h += '<button data-action="set-edit-pri" data-pri="' + p + '" style="' + (sel ? 'background:' + pc2.bg + ';color:#fff;border-color:' + pc2.bg : '') + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
h += '<textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea>';
h += '<div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + c.id + '">保存</button></div>';
h += '</div>';
} else {
h += '<div class="fb-body">' + esc(c.content) + '</div>';
}
if (state.editingId !== c.id) {
h += '<div class="fb-actions">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
h += '<button class="fb-act res" data-action="resolve" data-id="' + c.id + '">' + (c.resolved ? icon('rotateCcw', 12) : icon('check', 12)) + (c.resolved ? '戻す' : '解決') + '</button>';
if (isOwn) h += '<button class="fb-act" data-action="edit" data-id="' + c.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwn) h += '<button class="fb-act del" data-action="delete" data-id="' + c.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
const replies = state.comments.filter((r) => r.parentId === c.id).sort((a, b) => a.timestamp - b.timestamp);
if (replies.length > 0) {
h += '<div class="fb-replies">';
replies.forEach((r) => {
const isOwnReply = r.author === state.username;
h += '<div class="fb-reply-item"><div class="fb-reply-avatar">' + esc(r.author.charAt(0)) + '</div><div style="flex:1;min-width:0"><div class="fb-reply-meta"><strong>' + esc(r.author) + '</strong> &middot; ' + fmtTime(r.timestamp) + '</div>';
if (state.editingId === r.id) {
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
} else {
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
h += '<div class="fb-actions" style="margin-top:4px">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
h += '</div></div>';
});
h += '</div>';
}
if (state.replyingTo === c.id) {
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
}
h += '</div>';
return h;
}

View File

@ -0,0 +1,22 @@
import type { Priority } from '../../shared/types';
import { renderToggle } from './toggle';
import { renderSidebar, toggleSidebar } from './sidebar';
import { renderPopup } from './popup';
import { renderNameDialog } from './name-dialog';
let _closePopup: (() => void) | null = null;
let _submitComment: ((priority: Priority) => void) | null = null;
export function setRenderDeps(closePopup: () => void, submitComment: (priority: Priority) => void): void {
_closePopup = closePopup;
_submitComment = submitComment;
}
export function render(): void {
renderToggle(toggleSidebar);
renderSidebar(render);
renderPopup(render, _closePopup!, _submitComment!);
renderNameDialog(render);
}
export { toggleSidebar };

View File

@ -0,0 +1,26 @@
import { USERNAME_KEY } from '../../shared/constants';
import { el } from '../dom';
import { state } from '../state';
export function renderNameDialog(onRender: () => void): void {
const existing = document.getElementById('fb-name-overlay');
if (state.username) { if (existing) existing.remove(); return; }
if (existing) return;
const overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
document.body.appendChild(overlay);
const input = document.getElementById('fb-name-field') as HTMLInputElement;
const btn = document.getElementById('fb-name-submit') as HTMLButtonElement;
input.addEventListener('input', () => { btn.disabled = !input.value.trim(); });
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim(), onRender); } });
btn.addEventListener('click', () => { if (input.value.trim()) setName(input.value.trim(), onRender); });
setTimeout(() => { input.focus(); }, 100);
}
function setName(name: string, onRender: () => void): void {
state.username = name;
localStorage.setItem(USERNAME_KEY, name);
const overlay = document.getElementById('fb-name-overlay');
if (overlay) overlay.remove();
onRender();
}

View File

@ -0,0 +1,54 @@
import type { Priority } from '../../shared/types';
import { PRIORITY_COLORS } from '../../shared/constants';
import { el, esc } from '../dom';
import { state } from '../state';
export function renderPopup(onRender: () => void, closePopup: () => void, submitComment: (priority: Priority) => void): void {
let popup = document.getElementById('fb-popup');
const isNew = !popup;
if (isNew) {
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
document.body.appendChild(popup);
popup.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!t) return;
if (t.dataset.action === 'cancel-popup') { closePopup(); }
// 優先度ボタンを押した時点で、その優先度で即送信(ワンクリック)
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri as Priority; submitComment(state.popupPriority); }
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
});
popup.addEventListener('input', (e) => {
if ((e.target as HTMLElement).dataset.action === 'popup-textarea') state.popupContent = (e.target as HTMLTextAreaElement).value;
});
popup.addEventListener('keydown', (e) => {
if ((e.target as HTMLElement).dataset.action === 'popup-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitComment(state.popupPriority);
}
});
}
if (!state.selectedRect) { popup!.classList.remove('show'); return; }
popup!.classList.add('show');
const q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
let h = '<div class="fb-popup-head"><span>コメントを追加</span></div>';
h += '<div class="fb-popup-quote">' + esc(q) + '</div>';
h += '<textarea placeholder="フィードバックを入力...(優先度を押すと送信)" data-action="popup-textarea">' + esc(state.popupContent) + '</textarea>';
// ワンポチモードと同じ「ソリッド色=押すと送信」の優先度ボタン
h += '<div class="fb-popup-pri">';
(['must', 'better', 'want'] as const).forEach((p) => {
const pc = PRIORITY_COLORS[p];
h += '<button class="fb-pin-pri-btn" style="background:' + pc.bg + ';color:#fff;border:none" data-action="set-popup-pri" data-pri="' + p + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
popup!.innerHTML = h;
const rect = state.selectedRect;
const pw = 320, ph = 230, m = 12;
let top = rect.bottom + m;
if (top + ph > window.innerHeight) top = rect.top - ph - m;
if (top < m) top = Math.max(m, (window.innerHeight - ph) / 2);
const availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
const left = Math.max(m, Math.min(rect.left, availW - pw - m));
popup!.style.top = top + 'px';
popup!.style.left = left + 'px';
}

View File

@ -0,0 +1,201 @@
import type { Priority, FilterMode } from '../../shared/types';
import { SIDEBAR_WIDTH_KEY } from '../../shared/constants';
import { el, esc } from '../dom';
import { icon } from '../icons';
import { state } from '../state';
import { renderCard } from './card';
function applySidebarWidth(): void {
const sb = document.getElementById('fb-sidebar');
if (!sb) return;
const w = state.sidebarWidth;
sb.style.width = w + 'px';
sb.style.right = state.sidebarOpen ? '0px' : (-(w + 20)) + 'px';
document.body.style.marginRight = state.sidebarOpen ? w + 'px' : '';
}
export function toggleSidebar(): void {
state.sidebarOpen = !state.sidebarOpen;
const btn = document.getElementById('fb-toggle');
if (btn) btn.style.display = state.sidebarOpen ? 'none' : '';
document.body.style.transition = 'margin-right 0.3s ease';
applySidebarWidth();
}
function topLevel() { return state.comments.filter((c) => !c.parentId); }
function filtered() {
const tl = topLevel();
if (state.filter === 'resolved') return tl.filter((c) => c.resolved);
if (state.filter === 'all') return tl;
return tl.filter((c) => !c.resolved);
}
export function renderSidebar(onRender: () => void): void {
let sb = document.getElementById('fb-sidebar');
const isNew = !sb;
if (isNew) {
sb = el('div', { id: 'fb-sidebar' });
document.body.appendChild(sb);
sb.addEventListener('mouseover', (e) => {
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (!card) return;
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.add('fb-pulse');
});
sb.addEventListener('mouseout', (e) => {
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (!card) return;
if (e.relatedTarget && card.contains(e.relatedTarget as Node)) return;
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.remove('fb-pulse');
});
}
applySidebarWidth();
const tl = topLevel();
const counts = {
unresolved: tl.filter((c) => !c.resolved).length,
resolved: tl.filter((c) => c.resolved).length,
all: tl.length,
};
let html = '<div class="fb-resize-handle"></div>';
html += '<div class="fb-header">';
html += '<div class="fb-header-left"><span class="fb-header-title">コメント</span><span class="fb-header-count">' + counts.all + '</span></div>';
html += '<div class="fb-header-actions">';
html += '<button class="fb-hdr-btn" data-action="close" title="閉じる">' + icon('x', 18) + '</button>';
html += '</div></div>';
if (state.username) {
if (state.editingName) {
html += '<div class="fb-user-row"><input class="fb-name-input" value="' + esc(state.nameInput) + '" data-action="name-input" autofocus></div>';
} else {
html += '<div class="fb-user-row" data-action="edit-name">' + icon('user', 14) + esc(state.username) + '</div>';
}
}
html += '<div class="fb-filters">';
(['unresolved', 'resolved', 'all'] as const).forEach((f) => {
const label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて';
html += '<button class="fb-filter' + (state.filter === f ? ' active' : '') + '" data-filter="' + f + '">' + label + '<span class="cnt">' + counts[f] + '</span></button>';
});
html += '</div>';
const items = filtered().sort((a, b) => b.timestamp - a.timestamp);
html += '<div class="fb-list">';
if (items.length === 0) {
html += '<div class="fb-empty">' + icon('message', 40) + 'コメントはまだありません<br><span style="font-size:11px">テキストを選択してコメントを追加</span></div>';
} else {
items.forEach((c) => { html += renderCard(c); });
}
html += '</div>';
sb!.innerHTML = html;
if (isNew) bindSidebarEvents(sb!, onRender);
}
export interface SidebarActions {
toggleSidebar: () => void;
cyclePriority: (id: string) => void;
scrollToQuote: (id: string) => void;
scrollToPin: (id: string) => void;
resolveComment: (id: string) => void;
deleteComment: (id: string) => void;
deleteReply: (id: string) => void;
saveEdit: (id: string) => void;
submitReply: (id: string) => void;
finishNameEdit: () => void;
}
let _actions: SidebarActions | null = null;
export function setSidebarActions(actions: SidebarActions): void {
_actions = actions;
}
function bindSidebarEvents(sb: HTMLElement, onRender: () => void): void {
sb.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (t) {
const action = t.dataset.action;
const id = t.dataset.id;
if (action === 'close') { _actions?.toggleSidebar(); }
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; onRender(); }
else if (action === 'cycle' && id) { _actions?.cyclePriority(id); }
else if (action === 'scroll-quote' && id) { _actions?.scrollToQuote(id); }
else if (action === 'scroll-pin' && id) { _actions?.scrollToPin(id); }
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; onRender(); }
else if (action === 'resolve' && id) { _actions?.resolveComment(id); }
else if (action === 'edit' && id) { const c = state.comments.find((x) => x.id === id); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; onRender(); } }
else if (action === 'delete' && id) { _actions?.deleteComment(id); }
else if (action === 'delete-reply' && id) { _actions?.deleteReply(id); }
else if (action === 'cancel-edit') { state.editingId = null; onRender(); }
else if (action === 'save-edit' && id) { _actions?.saveEdit(id); }
else if (action === 'submit-reply' && id) { _actions?.submitReply(id); }
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri as Priority; onRender(); }
return;
}
const filterBtn = (e.target as HTMLElement).closest('[data-filter]') as HTMLElement | null;
if (filterBtn) { state.filter = filterBtn.dataset.filter as FilterMode; onRender(); return; }
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (card && card.dataset.id) {
const cm = state.comments.find((x) => x.id === card.dataset.id);
if (cm?.type === 'pin') _actions?.scrollToPin(card.dataset.id);
else _actions?.scrollToQuote(card.dataset.id);
}
});
sb.addEventListener('mousedown', (e) => {
if (!(e.target as HTMLElement).closest('.fb-resize-handle')) return;
e.preventDefault();
const startX = e.clientX;
const startWidth = state.sidebarWidth;
const handle = (e.target as HTMLElement).closest('.fb-resize-handle')!;
document.body.classList.add('fb-resizing');
handle.classList.add('active');
function onMove(ev: MouseEvent) {
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
const s = document.getElementById('fb-sidebar');
if (s) s.style.width = state.sidebarWidth + 'px';
document.body.style.transition = 'none';
document.body.style.marginRight = state.sidebarWidth + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('fb-resizing');
handle.classList.remove('active');
document.body.style.transition = '';
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(state.sidebarWidth));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
sb.addEventListener('input', (e) => {
const t = e.target as HTMLInputElement;
if (t.dataset.action === 'name-input') { state.nameInput = t.value; }
else if (t.dataset.action === 'edit-textarea') { state.editContent = t.value; }
else if (t.dataset.action === 'reply-textarea') { state.replyText = t.value; const btn = sb.querySelector('[data-action="submit-reply"]') as HTMLButtonElement | null; if (btn) btn.disabled = !state.replyText.trim(); }
});
sb.addEventListener('keydown', (e) => {
const t = e.target as HTMLElement;
if (t.dataset.action === 'name-input') {
if (e.key === 'Enter') { _actions?.finishNameEdit(); }
else if (e.key === 'Escape') { state.editingName = false; onRender(); }
}
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
const id = (sb.querySelector('[data-action="submit-reply"]') as HTMLElement | null)?.dataset.id;
if (id) _actions?.submitReply(id);
}
});
sb.addEventListener('blur', (e) => {
if ((e.target as HTMLElement).dataset?.action === 'name-input') { _actions?.finishNameEdit(); }
}, true);
}

View File

@ -0,0 +1,53 @@
import { el } from '../dom';
import { icon } from '../icons';
import { state } from '../state';
let _onToggleArea: (() => void) | null = null;
let _onVoice: (() => void) | null = null;
export function setToggleAreaHandler(handler: () => void): void {
_onToggleArea = handler;
}
export function setToggleVoiceHandler(handler: () => void): void {
_onVoice = handler;
}
export function renderToggle(toggleSidebar: () => void): void {
let btn = document.getElementById('fb-toggle');
if (!btn) {
btn = el('button', { id: 'fb-toggle' });
btn.addEventListener('click', (e) => {
const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (target?.dataset.action === 'area-mode') {
_onToggleArea?.();
} else if (target?.dataset.action === 'voice') {
_onVoice?.();
} else {
toggleSidebar();
}
});
document.body.appendChild(btn);
}
const unresolvedCount = state.comments.filter((c) => !c.parentId && !c.resolved).length;
const pinCount = state.comments.filter((c) => c.type === 'pin' && !c.parentId).length;
let h = '<span class="fb-toggle-icon">' + icon('panelRight', 16);
if (unresolvedCount > 0) h += '<span class="fb-badge">' + unresolvedCount + '</span>';
h += '</span>';
const pinStyle = state.pinMode ? 'color:var(--fb-accent);background:rgba(59,130,246,0.1);border-radius:4px' : '';
const pinTitle = state.pinMode
? 'フィードバックモード ONクリックで終了クリック=ピン・ドラッグ=引用'
: 'フィードバックモード OFFクリックで開始';
h += '<span class="fb-toggle-pin" data-action="area-mode" style="' + pinStyle + '" title="' + pinTitle + '">'
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v10"/><circle cx="12" cy="14" r="2"/><path d="M12 16v6"/></svg>';
if (pinCount > 0) h += '<span style="font-size:9px;font-weight:700;color:var(--fb-accent);margin-left:1px">' + pinCount + '</span>';
h += '</span>';
const voiceCls = state.voiceRecording ? ' recording' : state.voiceProcessing ? ' processing' : '';
const voiceTitle = state.voiceRecording ? '録音中(クリックで停止して解析)' : state.voiceProcessing ? '解析中...' : '音声でフィードバック';
// 解析中はマイクではなくスピナー(円形ローダー)を回す
const voiceIcon = state.voiceProcessing ? icon('loader', 14) : icon('mic', 14);
h += '<span class="fb-toggle-voice' + voiceCls + '" data-action="voice" title="' + voiceTitle + '">' + voiceIcon + '</span>';
h += '<span class="fb-toggle-label">コメント</span>';
btn.innerHTML = h;
btn.style.display = state.sidebarOpen ? 'none' : '';
}

40
src/widget/scroll.ts Normal file
View File

@ -0,0 +1,40 @@
import type { Priority } from '../shared/types';
import { state } from './state';
const PRIORITY_FLASH: Record<Priority, string> = {
must: 'rgba(239,68,68,0.4)',
better: 'rgba(245,158,11,0.4)',
want: 'rgba(34,197,94,0.4)',
};
export function scrollToQuote(id: string): void {
const mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]') as HTMLElement | null;
if (!mark) return;
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
const c = state.comments.find((x) => x.id === id);
const flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
const orig = mark.style.backgroundColor;
mark.style.backgroundColor = flashColor;
mark.style.transition = 'background-color 0.3s';
setTimeout(() => { mark.style.backgroundColor = orig; }, 1500);
}
export function scrollToPin(id: string): void {
const marker = document.querySelector('.fb-pin-marker[data-pin-id="' + id + '"]') as HTMLElement | null;
if (!marker) return;
marker.scrollIntoView({ behavior: 'smooth', block: 'center' });
marker.classList.add('fb-pin-flash');
setTimeout(() => { marker.classList.remove('fb-pin-flash'); }, 1500);
}
export function scrollToCard(id: string, toggleSidebar: () => void): void {
const wasClosed = !state.sidebarOpen;
if (wasClosed) { toggleSidebar(); }
setTimeout(() => {
const card = document.querySelector('.fb-card[data-id="' + id + '"]') as HTMLElement | null;
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('fb-focused');
setTimeout(() => { card.classList.remove('fb-focused'); }, 1500);
}, wasClosed ? 350 : 0);
}

36
src/widget/selection.ts Normal file
View File

@ -0,0 +1,36 @@
import { state, type QuoteContext } from './state';
export function getQuoteContext(range: Range): QuoteContext {
let before = '', after = '';
try {
const br = document.createRange();
br.setStart(document.body, 0);
br.setEnd(range.startContainer, range.startOffset);
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
const ar = document.createRange();
ar.setStart(range.endContainer, range.endOffset);
ar.setEnd(document.body, document.body.childNodes.length);
after = ar.toString().slice(0, 50).replace(/[\s\u00A0]+/g, ' ').trim();
} catch (_) { /* ignore */ }
return { beforeText: before, afterText: after };
}
export function setupTextSelection(onRender: () => void, closePopup: () => void): void {
document.addEventListener('mouseup', (e) => {
// テキスト選択は FB モードに関係なく常に有効(ドラッグ選択は意図的なので邪魔にならない)
if ((e.target as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-popup,#fb-name-overlay')) return;
const sel = window.getSelection();
const text = sel?.toString().trim();
if (!text || !sel || sel.rangeCount === 0) return; // 名前未設定でも可(送信時に匿名で扱う)
const range = sel.getRangeAt(0);
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
state.selectedRect = range.getBoundingClientRect();
state.selectedQuoteContext = getQuoteContext(range);
state.popupContent = '';
onRender();
});
document.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('#fb-popup')) return;
if (state.selectedRect) closePopup();
});
}

85
src/widget/state.ts Normal file
View File

@ -0,0 +1,85 @@
import type { Priority, FilterMode } from '../shared/types';
import { USERNAME_KEY, SIDEBAR_WIDTH_KEY } from '../shared/constants';
import { slugify } from '../shared/slug';
export interface QuoteContext {
beforeText: string;
afterText: string;
}
export interface FbComment {
id: string;
author: string;
type: string; // 'comment' | 'pin' など
quote: string;
quoteContext: QuoteContext;
content: string;
priority: Priority;
parentId: string | null;
resolved: boolean;
resolvedBy: string | null;
resolvedAt: number | null;
timestamp: number;
updatedAt: number | null;
pageUrl: string;
projectSlug?: string;
pinX?: number | null; // type==='pin' のときコンテナ幅に対する%(フォールバック)
pinY?: number | null; // type==='pin' のときコンテナ上端からのpxフォールバック
anchorSelector?: string | null; // 追従基準の最寄り要素セレクタ
anchorDx?: number | null; // 基準要素左上からの相対px
anchorDy?: number | null; // 基準要素左上からの相対px
}
export interface WidgetState {
username: string;
comments: FbComment[];
filter: FilterMode;
sidebarOpen: boolean;
selectedText: string;
selectedQuoteContext: QuoteContext;
selectedRect: DOMRect | null;
popupContent: string;
editingId: string | null;
editContent: string;
editPriority: Priority;
replyingTo: string | null;
replyText: string;
editingName: boolean;
nameInput: string;
popupPriority: Priority;
sidebarWidth: number;
pinMode: boolean;
pinPopupPos: { x: number; y: number } | null; // クリック位置(%,px
editingPinId: string | null; // 編集中ピンのIDnull=新規ピン追加)
voiceRecording: boolean; // 録音中
voiceProcessing: boolean; // 音声をGeminiで解析中
voiceError: string | null; // 直近のエラーメッセージ
}
export const state: WidgetState = {
username: localStorage.getItem(USERNAME_KEY) || '',
comments: [],
filter: 'unresolved',
sidebarOpen: false,
selectedText: '',
selectedQuoteContext: { beforeText: '', afterText: '' },
selectedRect: null,
popupContent: '',
editingId: null,
editContent: '',
editPriority: 'want',
replyingTo: null,
replyText: '',
editingName: false,
nameInput: '',
popupPriority: 'must',
sidebarWidth: parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) || '', 10) || 400,
pinMode: false,
pinPopupPos: null,
editingPinId: null,
voiceRecording: false,
voiceProcessing: false,
voiceError: null,
};
export const slug = slugify(window.location.href);

202
src/widget/styles.ts Normal file
View File

@ -0,0 +1,202 @@
export function injectStyles(): void {
const style = document.createElement('style');
style.id = 'fb-widget-styles';
// ピン型(涙型マーカー)カーソル。先端(下端中央)=ホットスポット 14,26
const pinCursor =
`url('data:image/svg+xml,%3Csvg%20xmlns="http://www.w3.org/2000/svg"%20width="28"%20height="28"%20viewBox="0%200%2028%2028"%3E%3Cpath%20d="M14%202a8%208%200%200%200-8%208c0%205.5%208%2016%208%2016s8-10.5%208-16a8%208%200%200%200-8-8Z"%20fill="%233b82f6"%20stroke="%23ffffff"%20stroke-width="1.6"%20stroke-linejoin="round"/%3E%3Ccircle%20cx="14"%20cy="10"%20r="3"%20fill="%23ffffff"/%3E%3C/svg%3E') 14 26, auto`;
style.textContent = [
':root{--fb-bg:#ffffff;--fb-fg:#0a0a0a;--fb-muted:#f5f5f5;--fb-muted-fg:#737373;--fb-border:#e5e5e5;--fb-primary:#171717;--fb-primary-fg:#fafafa;--fb-accent:#3b82f6;--fb-destructive:#ef4444;--fb-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans",sans-serif}',
'#fb-toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:36px;background:var(--fb-bg);border:1px solid var(--fb-border);border-right:none;border-radius:8px 0 0 8px;cursor:pointer;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:var(--fb-font);transition:all .15s;box-shadow:-2px 0 8px rgba(0,0,0,0.06);padding:8px 0}',
'#fb-toggle:hover{background:var(--fb-muted);box-shadow:-2px 0 12px rgba(0,0,0,0.1)}',
'#fb-toggle .fb-toggle-icon{color:var(--fb-muted-fg);display:flex;align-items:center;justify-content:center;position:relative}',
'#fb-toggle .fb-toggle-label{font-size:10px;color:var(--fb-accent);font-weight:700;writing-mode:vertical-rl;letter-spacing:1px;line-height:1;white-space:nowrap}',
'#fb-toggle .fb-badge{position:absolute;top:-6px;left:-6px;background:var(--fb-destructive);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:flex;align-items:center;justify-content:center;padding:0 3px;line-height:1}',
'#fb-sidebar{position:fixed;top:0;height:100vh;background:rgba(245,245,245,0.5);border-left:1px solid var(--fb-border);z-index:99998;transition:right .3s ease;display:flex;flex-direction:column;font-family:var(--fb-font);font-size:14px;color:var(--fb-fg)}',
'#fb-sidebar *{box-sizing:border-box}',
'.fb-resize-handle{position:absolute;left:-3px;top:0;width:6px;height:100%;cursor:col-resize;z-index:1}',
'.fb-resize-handle:hover{background:rgba(59,130,246,0.15)}',
'.fb-resize-handle.active{background:rgba(59,130,246,0.3)}',
'body.fb-resizing{cursor:col-resize !important;-webkit-user-select:none !important;user-select:none !important}',
'body.fb-resizing *{cursor:col-resize !important}',
'.fb-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--fb-border)}',
'.fb-header-left{display:flex;align-items:center;gap:8px}',
'.fb-header-title{font-size:14px;font-weight:700;color:var(--fb-accent)}',
'.fb-header-count{background:var(--fb-muted);color:var(--fb-muted-fg);font-size:11px;padding:1px 7px;border-radius:10px}',
'.fb-header-actions{display:flex;align-items:center;gap:2px}',
'.fb-hdr-btn{background:none;border:none;cursor:pointer;padding:6px;border-radius:6px;color:var(--fb-muted-fg);transition:all .15s;display:inline-flex;align-items:center;gap:4px;font-family:var(--fb-font);font-size:12px}',
'.fb-hdr-btn:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-user-row{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--fb-border);font-size:13px;color:var(--fb-muted-fg);cursor:pointer;transition:color .15s;gap:6px}',
'.fb-user-row:hover{color:var(--fb-fg)}',
'.fb-filters{display:flex;border-bottom:1px solid var(--fb-border)}',
'.fb-filter{flex:1;padding:10px 0;text-align:center;font-size:13px;color:var(--fb-muted-fg);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-filter:hover{color:var(--fb-fg)}',
'.fb-filter.active{color:var(--fb-fg);border-bottom-color:var(--fb-accent)}',
'.fb-filter .cnt{font-size:11px;margin-left:4px;padding:1px 5px;border-radius:8px;background:var(--fb-muted);color:var(--fb-muted-fg)}',
'.fb-filter.active .cnt{background:rgba(59,130,246,0.1);color:var(--fb-accent)}',
'.fb-list{flex:1;overflow-y:auto;padding:8px}',
'.fb-empty{text-align:center;padding:60px 20px;color:var(--fb-muted-fg);font-size:13px}',
'.fb-empty svg{margin:0 auto 12px;display:block;color:var(--fb-border)}',
'.fb-card{background:var(--fb-bg);border:1px solid var(--fb-border);border-left:3px solid var(--fb-border);border-radius:12px;padding:14px;margin-bottom:6px;transition:box-shadow .3s,background .3s;cursor:pointer}',
'.fb-card:hover{box-shadow:0 1px 3px rgba(0,0,0,0.05)}',
'.fb-card.resolved{opacity:.5}',
'.fb-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}',
'.fb-card-head-left{display:flex;align-items:center;gap:6px}',
'.fb-avatar{width:22px;height:22px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}',
'.fb-author{font-size:13px;font-weight:700;color:var(--fb-fg)}',
'.fb-time{font-size:11px;color:var(--fb-muted-fg)}',
'.fb-resolved-mark{font-size:11px;color:#22c55e;margin-left:4px;display:inline-flex;align-items:center;gap:2px}',
'.fb-badge-p{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;cursor:default;transition:all .15s}',
'.fb-badge-p.own{cursor:pointer}',
'.fb-badge-p.own:hover{transform:scale(1.1);box-shadow:0 0 0 2px rgba(0,0,0,0.08)}',
'.fb-quote{font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-left:2px solid var(--fb-primary);border-radius:0 4px 4px 0;margin-bottom:8px;font-style:italic;line-height:1.5;cursor:pointer;transition:background .15s}',
'.fb-quote:hover{background:var(--fb-border)}',
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
'.fb-actions{display:flex;gap:2px;flex-wrap:wrap}',
'.fb-act{font-size:12px;color:#a3a3a3;background:none;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px;font-family:var(--fb-font)}',
'.fb-act:hover{color:var(--fb-fg)}',
'.fb-act.del:hover{color:var(--fb-destructive)}',
'.fb-act.res:hover{color:#22c55e}',
'.fb-replies{margin-top:8px;padding-top:8px;border-top:1px solid var(--fb-muted)}',
'.fb-reply-item{display:flex;gap:8px;padding:6px 0}',
'.fb-reply-item+.fb-reply-item{border-top:1px solid var(--fb-muted)}',
'.fb-reply-avatar{width:20px;height:20px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:2px}',
'.fb-reply-meta{font-size:12px;color:var(--fb-muted-fg);margin-bottom:2px}',
'.fb-reply-meta strong{color:#525252;font-weight:700}',
'.fb-reply-text{font-size:13px;color:var(--fb-muted-fg);line-height:1.5}',
'.fb-reply-input{display:flex;gap:8px;margin-top:8px}',
'.fb-reply-input textarea{flex:1;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:none;outline:none;min-height:36px;color:var(--fb-fg)}',
'.fb-reply-input textarea:focus{border-color:var(--fb-accent)}',
'.fb-reply-input button{padding:6px 14px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:6px;font-size:12px;cursor:pointer;font-family:var(--fb-font);align-self:flex-end}',
'.fb-reply-input button:disabled{opacity:.4;cursor:default}',
'.fb-edit-area textarea{width:100%;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:vertical;outline:none;min-height:50px;margin-bottom:6px;color:var(--fb-fg)}',
'.fb-edit-area textarea:focus{border-color:var(--fb-accent)}',
'.fb-edit-btns{display:flex;gap:6px}',
'.fb-edit-btns button{padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font)}',
'.fb-edit-btns button.save{background:var(--fb-primary);color:var(--fb-primary-fg);border-color:var(--fb-primary)}',
'.fb-edit-pri{display:flex;gap:4px;margin-bottom:6px}',
'.fb-edit-pri button{padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font);transition:all .15s}',
'.fb-popup{position:fixed;z-index:100000;width:400px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.1);font-family:var(--fb-font);display:none}',
'.fb-popup.show{display:block}',
'.fb-popup-head{margin-bottom:10px}',
'.fb-popup-head span{font-size:14px;font-weight:700;color:var(--fb-fg)}',
'.fb-popup-quote{font-size:13px;color:var(--fb-muted-fg);padding:8px 12px;background:var(--fb-muted);border-left:2px solid var(--fb-accent);border-radius:0 6px 6px 0;margin-bottom:10px;font-style:italic;line-height:1.5}',
'.fb-popup textarea{width:100%;min-height:70px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg)}',
'.fb-popup textarea:focus{border-color:var(--fb-accent)}',
'.fb-popup textarea::placeholder{color:var(--fb-muted-fg)}',
'.fb-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
'.fb-popup-pri button{flex:1;padding:7px 8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
'.fb-popup-actions button{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions .cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
'.fb-popup-actions .cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-popup-actions .submit{border:none;color:#fff}',
'.fb-name-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100001}',
'.fb-name-box{background:var(--fb-bg);border-radius:12px;padding:28px;width:360px;box-shadow:0 20px 40px rgba(0,0,0,0.15)}',
'.fb-name-box h2{font-size:18px;font-weight:700;margin:0 0 6px;color:var(--fb-fg)}',
'.fb-name-box p{font-size:14px;color:var(--fb-muted-fg);margin:0 0 16px}',
'.fb-name-box input{width:100%;padding:10px 14px;border:1px solid var(--fb-border);border-radius:8px;font-size:15px;outline:none;margin-bottom:12px;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-box input:focus{border-color:var(--fb-accent)}',
'.fb-name-box input::placeholder{color:var(--fb-muted-fg)}',
'.fb-name-box button{width:100%;padding:10px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:var(--fb-font)}',
'.fb-name-box button:disabled{opacity:.4;cursor:default}',
'.fb-name-input{width:100%;padding:4px 8px;border:1px solid var(--fb-border);border-radius:4px;font-size:12px;outline:none;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-input:focus{border-color:var(--fb-accent)}',
'.fb-highlight{padding:1px 0;cursor:pointer;transition:background .15s}',
'.fb-highlight-must{background:rgba(239,68,68,0.15);border-bottom:2px solid #ef4444}',
'.fb-highlight-must:hover{background:rgba(239,68,68,0.25)}',
'.fb-highlight-better{background:rgba(245,158,11,0.15);border-bottom:2px solid #f59e0b}',
'.fb-highlight-better:hover{background:rgba(245,158,11,0.25)}',
'.fb-highlight-want{background:rgba(34,197,94,0.15);border-bottom:2px solid #22c55e}',
'.fb-highlight-want:hover{background:rgba(34,197,94,0.25)}',
'@keyframes fb-pulse{0%,100%{opacity:1}50%{opacity:0.4}}',
'.fb-highlight.fb-pulse{animation:fb-pulse 1s ease-in-out infinite}',
'.fb-card.fb-focused{box-shadow:0 0 0 2px var(--fb-accent);background:rgba(59,130,246,0.04)}',
'#fb-sidebar svg,#fb-toggle svg,.fb-popup svg{pointer-events:none}',
// Pin mode styles
'body.fb-pin-mode{cursor:' + pinCursor + '}',
'body.fb-pin-mode *:not(#fb-toggle):not(#fb-toggle *):not(#fb-sidebar):not(#fb-sidebar *):not(#fb-pin-popup):not(#fb-pin-popup *):not(.fb-pin-marker):not(.fb-pin-marker *){cursor:' + pinCursor + '}',
'.fb-toggle-pin{display:flex;align-items:center;justify-content:center;padding:4px;cursor:pointer;transition:all .15s}',
'.fb-toggle-pin:hover{color:var(--fb-accent)}',
// Voice (mic) button
'.fb-toggle-voice{display:flex;align-items:center;justify-content:center;padding:4px;cursor:pointer;color:var(--fb-muted-fg);border-radius:4px;transition:all .15s}',
'.fb-toggle-voice:hover{color:var(--fb-accent)}',
'.fb-toggle-voice.recording{color:#ef4444;background:rgba(239,68,68,0.12);animation:fb-mic-pulse 1.1s ease-in-out infinite}',
'.fb-toggle-voice.processing{color:var(--fb-accent)}',
'.fb-toggle-voice.processing svg{animation:fb-mic-spin .8s linear infinite;transform-origin:center}',
'@keyframes fb-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,0.5)}50%{box-shadow:0 0 0 5px rgba(239,68,68,0)}}',
'@keyframes fb-mic-spin{to{transform:rotate(360deg)}}',
// 音声解析の失敗を知らせるトースト(黙って先頭にピンが溜まるのを防ぐ)
'.fb-voice-toast{position:fixed;right:16px;bottom:70px;z-index:100001;max-width:320px;padding:10px 14px;border-radius:8px;background:var(--fb-fg);color:var(--fb-bg);font-family:var(--fb-font);font-size:13px;line-height:1.5;box-shadow:0 6px 20px rgba(0,0,0,0.2);opacity:0;transform:translateY(6px);transition:opacity .2s,transform .2s;pointer-events:none}',
'.fb-voice-toast.show{opacity:1;transform:translateY(0)}',
// 掴んで移動できることを示す。フィードバックモードの ON/OFF に関わらず操作できる
'.fb-pin-marker{position:absolute;transform:translate(-50%,-100%);cursor:grab;z-index:99990;transition:transform .15s}',
'.fb-pin-marker:hover{transform:translate(-50%,-100%) scale(1.15)}',
// 解決済みピンは淡色表示
'.fb-pin-marker.resolved{opacity:.4}',
// サイドバーのカードから飛んだとき、マーカーを点滅させる
'.fb-pin-marker.fb-pin-flash .fb-pin-icon{animation:fb-pin-ring 1.4s ease}',
'@keyframes fb-pin-ring{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.25)}30%,60%{box-shadow:0 0 0 8px rgba(59,130,246,0.45)}}',
// サイドバーのピンカード内インジケータ
'.fb-pin-ref{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-radius:6px;margin-bottom:8px;cursor:pointer;transition:background .15s}',
'.fb-pin-ref:hover{background:var(--fb-border)}',
'.fb-pin-ref-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;color:#fff;font-size:10px;font-weight:700;flex-shrink:0}',
'body.fb-dragging-pin,body.fb-dragging-pin *{cursor:grabbing !important;-webkit-user-select:none !important;user-select:none !important}',
// ドラッグ中はホバー拡大アニメを止めてカクつきを防ぐ
'body.fb-dragging-pin .fb-pin-marker{transition:none}',
'body.fb-dragging-pin .fb-pin-tooltip{display:none !important}',
// ホバー吹き出しの優先度バッジ(クリックで循環)
'.fb-pin-badge{cursor:pointer;transition:transform .1s}',
'.fb-pin-badge:hover{transform:scale(1.08)}',
'.fb-pin-badge.readonly{cursor:default}',
'.fb-pin-icon{width:24px;height:24px;border-radius:50%;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.25);position:relative}',
'.fb-pin-icon::after{content:"";position:absolute;bottom:-5px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid var(--pin-color,#3b82f6)}',
'.fb-pin-tooltip{display:none;position:absolute;top:8px;left:28px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:8px;padding:10px 12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);min-width:180px;max-width:280px;z-index:99995;font-family:var(--fb-font)}',
'.fb-pin-marker:hover .fb-pin-tooltip{display:block}',
'.fb-pin-delete{position:absolute;top:4px;right:4px;width:18px;height:18px;border-radius:50%;background:rgba(0,0,0,0.5);color:#fff;border:none;font-size:13px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s}',
'.fb-pin-tooltip:hover .fb-pin-delete{opacity:1}',
'.fb-pin-popup{position:fixed;z-index:100000;width:300px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.12);font-family:var(--fb-font);display:none}',
'.fb-pin-popup.show{display:block}',
'.fb-pin-popup-head{font-size:14px;font-weight:700;color:var(--fb-fg);margin-bottom:10px}',
'.fb-pin-popup textarea{width:100%;min-height:60px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg);box-sizing:border-box}',
'.fb-pin-popup textarea:focus{border-color:var(--fb-accent)}',
'.fb-pin-popup textarea::placeholder{color:var(--fb-muted-fg)}',
'.fb-pin-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
'.fb-pin-pri-btn{flex:1;padding:6px 8px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;border:none;transition:opacity .15s}',
'.fb-pin-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
'.fb-pin-popup-actions button{padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
'.fb-pin-cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
'.fb-pin-cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-pin-submit{background:var(--fb-accent);border:none;color:#fff}',
'.fb-pin-submit:hover{background:#2563eb}',
].join('\n');
document.head.appendChild(style);
}

326
src/widget/voice.ts Normal file
View File

@ -0,0 +1,326 @@
import type { Priority } from '../shared/types';
import { state } from './state';
import { addPin, renderPins, elementToPinCoords, anchorFromElement, type PinAnchor } from './pin';
import { voiceApi, type VoiceResult } from './api';
interface Target {
index: number;
text: string; // その要素に表示されている文字
heading: string; // 直前の見出しGemini に渡す文脈)
isHeading: boolean; // 見出し要素そのものか
x: number; // ピン座標系(%
y: number; // ピン座標系px
anchor: PinAnchor; // 要素アンカー(レスポンシブ追従)
}
let mediaRecorder: MediaRecorder | null = null;
let chunks: BlobPart[] = [];
let stream: MediaStream | null = null;
let onChange: () => void = () => {};
/** 録音状態が変わったときに UI を再描画させるためのコールバックを登録する。 */
export function setVoiceOnChange(cb: () => void): void {
onChange = cb;
}
function getContainer(): HTMLElement {
return document.querySelector('main') || document.body;
}
const BLOCK_SELECTOR = 'div,p,li,td,th,dt,dd,figcaption,blockquote,h1,h2,h3,h4,h5,h6';
const HEADING_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
const WIDGET_UI = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip,.fb-voice-toast';
const MIN_TEXT = 2; // 「1」のような装飾番号を除く
const MAX_TEXT = 120; // これより長いものは「まとまり」ではなく本文の塊とみなす
const MAX_TARGETS = 600;
/**
*
*
*
*
*
* Gemini
*/
export function extractTargets(): Target[] {
const container = getContainer();
// テキストを持つブロックのうち、内側に別のブロックを含まないもの=最小単位
const blocks = (Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[]).filter(
(el) => (el.textContent || '').trim() && !el.closest(WIDGET_UI),
);
const leaves = new Set(blocks.filter((el) => !blocks.some((o) => o !== el && el.contains(o))));
const targets: Target[] = [];
let heading = '';
// 文書順に走査して「直前の見出し」を引き継ぎながら候補を積む
(Array.from(container.querySelectorAll('*')) as HTMLElement[]).forEach((el) => {
const isHeading = HEADING_TAGS.includes(el.tagName);
if (isHeading) {
const t = normalize(el.textContent || '');
if (t) heading = t;
}
if (!leaves.has(el) || targets.length >= MAX_TARGETS) return;
const text = normalize(el.textContent || '');
if (text.length < MIN_TEXT || text.length > MAX_TEXT) return;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return; // 非表示・つぶれた要素
const { x, y } = elementToPinCoords(el);
targets.push({
index: targets.length,
text,
// 見出し自身は自分の文言と重複するので文脈を持たせない
heading: isHeading ? '' : heading,
isHeading,
x,
y,
anchor: anchorFromElement(el),
});
});
return targets;
}
function normalize(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
export function toggleRecording(): void {
if (state.voiceProcessing) return;
if (state.voiceRecording) {
stopRecording();
} else {
void startRecording();
}
}
export async function startRecording(): Promise<void> {
if (state.voiceRecording || state.voiceProcessing) return;
state.voiceError = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
state.voiceError = 'マイクへのアクセスが許可されませんでした';
onChange();
return;
}
chunks = [];
const mime = pickMime();
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = () => {
void handleStop();
};
mediaRecorder.start();
state.voiceRecording = true;
onChange();
}
export function stopRecording(): void {
if (!state.voiceRecording || !mediaRecorder) return;
mediaRecorder.stop();
state.voiceRecording = false;
state.voiceProcessing = true;
onChange();
}
async function handleStop(): Promise<void> {
// マイクを解放
stream?.getTracks().forEach((t) => t.stop());
stream = null;
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
chunks = [];
const targets = extractTargets();
if (targets.length === 0) {
state.voiceError = 'ピンを置ける要素が見つかりませんでした';
state.voiceProcessing = false;
onChange();
return;
}
if (targets.length >= MAX_TARGETS) {
// 上限で打ち切ると後半にピンを置けない。黙って効かないより伝える。
state.voiceError = `ページが大きいため、先頭${MAX_TARGETS}箇所のみを対象にしています`;
}
try {
// 録音(webm/mp4等) を Gemini が確実に扱える 16kHz モノラル WAV に変換して送る
const base64 = await blobToWavBase64(blob);
const resp = await voiceApi(
base64,
'audio/wav',
targets.map((t) => ({ index: t.index, text: t.text, heading: t.heading, isHeading: t.isHeading })),
);
if (resp.error) {
state.voiceError = resp.error;
} else {
placePins(resp.results || [], targets);
}
} catch {
state.voiceError = '音声の解析に失敗しました';
} finally {
state.voiceProcessing = false;
onChange();
}
}
function placePins(results: VoiceResult[], targets: Target[]): void {
if (!Array.isArray(results) || results.length === 0) {
state.voiceError = 'フィードバックを聞き取れませんでした。もう一度お試しください';
return;
}
let misplaced = 0;
results.forEach((r) => {
let target = targets.find((t) => t.index === r.target_index);
if (!target) {
// Gemini が該当なし(-1)や範囲外を返した場合。発言に含まれる語句と
// 要素のテキストを突き合わせて置き場所を探す。
target = findByText(`${r.quote || ''} ${r.comment || ''}`, targets);
}
if (!target) {
// それでも決まらなければ捨てずに先頭へ。位置は当てにならないと伝える。
target = targets[0];
misplaced++;
}
if (!target) return;
addPin(target.x, target.y, r.comment, normalizePriority(r.priority), target.anchor);
});
if (misplaced > 0) {
state.voiceError = `${misplaced}件は場所を特定できませんでした(先頭に置いています。ドラッグで移動できます)`;
}
renderPins();
}
const MATCH_THRESHOLD = 0.34;
const MATCH_MIN_LEN = 4;
/**
* 2
*
* 023
*
*
*/
function findByText(query: string, targets: Target[]): Target | undefined {
const q = bigrams(query);
if (q.size === 0) return undefined;
let best: Target | undefined;
let bestScore = MATCH_THRESHOLD;
targets.forEach((t) => {
if (t.text.length < MATCH_MIN_LEN) return;
const score = overlap(q, bigrams(t.text));
if (score > bestScore || (best && score === bestScore && t.text.length > best.text.length)) {
bestScore = score;
best = t;
}
});
return best;
}
function bigrams(s: string): Set<string> {
const t = s.replace(/\s+/g, '').toLowerCase();
const out = new Set<string>();
for (let i = 0; i < t.length - 1; i++) out.add(t.slice(i, i + 2));
return out;
}
/** 短い方に対する一致率。要素テキストは短いので Jaccard より当たりやすい。 */
function overlap(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0;
let hit = 0;
a.forEach((g) => { if (b.has(g)) hit++; });
return hit / Math.min(a.size, b.size);
}
function normalizePriority(p: string): Priority {
return p === 'must' || p === 'better' || p === 'want' ? p : 'better';
}
/** MediaRecorder が対応する音声形式を優先順に選ぶ。 */
function pickMime(): string {
const cands = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/mp4'];
for (const c of cands) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(c)) return c;
}
return '';
}
/**
* Blob 16kHz WAV (base64)
* Gemini wav/mp3/aiff/aac/ogg/flac
* 16kHz
*/
async function blobToWavBase64(blob: Blob): Promise<string> {
const arrayBuf = await blob.arrayBuffer();
const AudioCtx: typeof AudioContext =
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new AudioCtx();
let decoded: AudioBuffer;
try {
decoded = await ctx.decodeAudioData(arrayBuf);
} finally {
void ctx.close();
}
const targetRate = 16000;
const length = Math.max(1, Math.ceil(decoded.duration * targetRate));
// チャンネル数1の出力に繋ぐと自動でモラルにダウンミックスされる
const offline = new OfflineAudioContext(1, length, targetRate);
const src = offline.createBufferSource();
src.buffer = decoded;
src.connect(offline.destination);
src.start();
const rendered = await offline.startRendering();
const wav = encodeWav(rendered.getChannelData(0), targetRate);
return arrayBufferToBase64(wav);
}
/** Float32 PCM を 16bit モノラル WAV (ArrayBuffer) にエンコードする。 */
function encodeWav(samples: Float32Array, sampleRate: number): ArrayBuffer {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeStr = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
};
writeStr(0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true); // PCM チャンクサイズ
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // モノラル
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); // byteRate = rate * blockAlign
view.setUint16(32, 2, true); // blockAlign = ch * bytesPerSample
view.setUint16(34, 16, true); // bitsPerSample
writeStr(36, 'data');
view.setUint32(40, samples.length * 2, true);
let off = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
off += 2;
}
return buffer;
}
function arrayBufferToBase64(buf: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}

129
tests/shared.unit.test.ts Normal file
View File

@ -0,0 +1,129 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { slugify, generateId } from '../src/shared/slug';
import { fmtTime } from '../src/shared/time';
import { PRIORITY_CYCLE, PRIORITY_COLORS, HIGHLIGHT_COLORS } from '../src/shared/constants';
import { authHeaders } from '../src/shared/api-client';
import { mapNormToOrig } from '../src/widget/highlight';
describe('slugify', () => {
test('HTTP プレフィックスを除去する', () => {
expect(slugify('https://example.com/page')).toBe('example_com_page');
});
test('日本語文字を保持する', () => {
const result = slugify('https://example.com/テスト');
expect(result).toContain('テスト');
});
test('100文字で切り詰める', () => {
const long = 'https://example.com/' + 'a'.repeat(200);
expect(slugify(long).length).toBe(100);
});
test('記号をアンダースコアに変換する', () => {
expect(slugify('https://a.b/c?d=e&f=g')).toBe('a_b_c_d_e_f_g');
});
});
describe('generateId', () => {
test('文字列を返す', () => {
expect(typeof generateId()).toBe('string');
});
test('一意性がある100回生成してユニーク', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
expect(ids.size).toBe(100);
});
});
describe('fmtTime', () => {
let realNow: () => number;
beforeEach(() => {
realNow = Date.now;
vi.spyOn(Date, 'now').mockReturnValue(1700000000000);
});
afterEach(() => {
vi.restoreAllMocks();
});
test('1分未満は「たった今」', () => {
expect(fmtTime(1700000000000 - 30000)).toBe('たった今');
});
test('1時間未満は「N分前」', () => {
expect(fmtTime(1700000000000 - 300000)).toBe('5分前');
});
test('24時間未満は「N時間前」', () => {
expect(fmtTime(1700000000000 - 7200000)).toBe('2時間前');
});
test('24時間以上は日時文字列', () => {
const result = fmtTime(1700000000000 - 86400000 * 2);
expect(result).toMatch(/\d+月/);
});
});
describe('PRIORITY_CYCLE', () => {
test('must → better → want → must の循環', () => {
expect(PRIORITY_CYCLE.must).toBe('better');
expect(PRIORITY_CYCLE.better).toBe('want');
expect(PRIORITY_CYCLE.want).toBe('must');
});
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(PRIORITY_CYCLE)).toEqual(['must', 'better', 'want']);
});
});
describe('PRIORITY_COLORS', () => {
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(PRIORITY_COLORS)).toEqual(['must', 'better', 'want']);
});
test('各色に bg, text, light, border がある', () => {
for (const p of ['must', 'better', 'want'] as const) {
expect(PRIORITY_COLORS[p]).toHaveProperty('bg');
expect(PRIORITY_COLORS[p]).toHaveProperty('text');
expect(PRIORITY_COLORS[p]).toHaveProperty('light');
expect(PRIORITY_COLORS[p]).toHaveProperty('border');
}
});
});
describe('HIGHLIGHT_COLORS', () => {
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(HIGHLIGHT_COLORS)).toEqual(['must', 'better', 'want']);
});
});
describe('authHeaders', () => {
test('トークンありの場合 Authorization を含む', () => {
const h = authHeaders('my-token');
expect(h['Authorization']).toBe('Bearer my-token');
expect(h['Content-Type']).toBe('application/json');
});
test('トークンなしの場合 Authorization を含まない', () => {
const h = authHeaders('');
expect(h['Authorization']).toBeUndefined();
expect(h['Content-Type']).toBe('application/json');
});
});
describe('mapNormToOrig', () => {
test('空白なしの単純マッチ', () => {
expect(mapNormToOrig('hello world', 0, 5)).toEqual([0, 5]);
});
test('余分な空白がある場合の正規化マッチ', () => {
// 正規化後のインデックス 6-11 は orig で 6-12 に対応(余分な空白を含む)
expect(mapNormToOrig('hello world', 6, 11)).toEqual([6, 12]);
});
test('範囲外の normStart は null を返す', () => {
expect(mapNormToOrig('short', 100, 105)).toBeNull();
});
});

View File

@ -0,0 +1,168 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
const WIDGET_JS = readFileSync(
resolve(__dirname, '../public/widget.js'),
'utf-8'
);
function loadWidget() {
document.head.innerHTML = '';
document.body.innerHTML = '<p>テストテキスト</p>';
const script = document.createElement('script');
script.src = 'https://my-app.vercel.app/widget.js';
script.dataset.token = 'test-token-abc';
Object.defineProperty(document, 'currentScript', {
value: script,
writable: true,
configurable: true,
});
window.localStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([]),
ok: true,
})
)
);
// eslint-disable-next-line no-eval
eval(WIDGET_JS);
}
describe('widget.js 初期化', () => {
beforeEach(() => {
loadWidget();
});
test('トグルボタンが DOM に追加される', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle).toBeTruthy();
expect(toggle!.tagName).toBe('BUTTON');
});
test('サイドバーが DOM に追加される', () => {
const sidebar = document.getElementById('fb-sidebar');
expect(sidebar).toBeTruthy();
});
test('スタイルが注入される', () => {
const style = document.getElementById('fb-widget-styles');
expect(style).toBeTruthy();
expect(style!.tagName).toBe('STYLE');
});
test('CSS 変数が定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('--fb-bg');
expect(css).toContain('--fb-fg');
expect(css).toContain('--fb-accent');
expect(css).toContain('--fb-border');
expect(css).toContain('--fb-muted');
expect(css).toContain('--fb-primary');
expect(css).toContain('--fb-destructive');
});
test('トグルボタンに「コメント」ラベルがある', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.innerHTML).toContain('コメント');
});
test('サイドバーにフィルタボタンがある', () => {
const sidebar = document.getElementById('fb-sidebar');
const html = sidebar!.innerHTML;
expect(html).toContain('未解決');
expect(html).toContain('解決済');
expect(html).toContain('すべて');
});
test('サイドバーにリサイズハンドルがある', () => {
const sidebar = document.getElementById('fb-sidebar');
const handle = sidebar!.querySelector('.fb-resize-handle');
expect(handle).toBeTruthy();
});
test('サイドバーは初期状態で非表示(右にオフセット)', () => {
const sidebar = document.getElementById('fb-sidebar') as HTMLElement;
const right = parseInt(sidebar.style.right, 10);
expect(right).toBeLessThan(0);
});
test('名前未入力時に名前入力ダイアログが表示される', () => {
const overlay = document.getElementById('fb-name-overlay');
expect(overlay).toBeTruthy();
expect(overlay!.innerHTML).toContain('ようこそ');
});
});
describe('widget.js ハイライト CSS', () => {
beforeEach(() => {
loadWidget();
});
test('ハイライトスタイルが優先度別に3色定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-highlight-must');
expect(css).toContain('.fb-highlight-better');
expect(css).toContain('.fb-highlight-want');
});
test('カードスタイルが定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-card');
expect(css).toContain('.fb-card-head');
expect(css).toContain('.fb-badge-p');
});
test('ポップアップスタイルが定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-popup');
expect(css).toContain('.fb-popup-pri');
expect(css).toContain('.fb-popup-actions');
});
});
describe('widget.js コメントなし状態', () => {
beforeEach(() => {
loadWidget();
});
test('コメントゼロ時にサイドバーに空状態メッセージが表示される', () => {
const sidebar = document.getElementById('fb-sidebar');
const html = sidebar!.innerHTML;
expect(html).toContain('コメントはまだありません');
});
test('コメントゼロ時にバッジが表示されない', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.querySelector('.fb-badge')).toBeNull();
});
});
describe('widget.js SVG アイコン', () => {
beforeEach(() => {
loadWidget();
});
test('トグルボタンに SVG アイコンが含まれる', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.innerHTML).toContain('<svg');
});
test('サイドバーの閉じるボタンに SVG アイコンが含まれる', () => {
const sidebar = document.getElementById('fb-sidebar');
const closeBtn = sidebar!.querySelector('[data-action="close"]');
expect(closeBtn).toBeTruthy();
expect(closeBtn!.innerHTML).toContain('<svg');
});
});

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "src/widget", "tests"]
}

3
vercel.json Normal file
View File

@ -0,0 +1,3 @@
{
"framework": "nextjs"
}

8
vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.ts'],
},
});