---
title: "考察記事の顔を、問いの文字に変えた ― 次の記事から自動でできる"
slug: question-typography-thumbnails
date: 2026-10-26
day: 388
meta_description: "考察記事のサムネイルを、Geminiに1本ずつ頼む4コマの絵から、記事の問いを明朝の文字で組んだ形に変えた。3つの案から強弱の付け方を選んで67本を差し替え、次の記事からは記事の頭の2行で自動でできるようにした記録。"
tags: [サムネイル, タイポグラフィ, 縦書き, Webデザイン, 自動化, Claude]
series: "ひとり研究所の裏側"
---

ブログやメディアの記事一覧には、記事ごとに小さな絵（サムネイル）が並びます。このサイトの「考察記事」は、ロボットやフィジカルAI（カメラやセンサーで周りを見て、AIが動きを決め、ロボットの体を動かす仕組み）について考えたことを、AIとの対話の形で残している記事です。その67本のサムネイルは、どれも Google の画像生成 AI「Gemini」に1本ずつ頼んで描いてもらった、4コマ漫画のような絵でした。前に数えたときは（LOG147）、頼んで、確かめて、直してもらうまでに1本5〜10分かかっていました。記事を書くたびに、別の画面で絵を頼む手間が残っていたわけです。

> 考察記事のサムネイルを新しくしようと思います（Geminiにサムネイルを作ってもらっていますが、わざわざ頼むのが地味に大変なので、通常のサムネイルと一緒に作ってもらいたいです）

そこで、絵をやめて、記事の「問い」を文字で組む形に変えました。たとえば「70万円のアームで、仕事を請けられるのか。」という問いを縦書きで置き、その中の「70万円」だけを大きく、問いの結びの「請けられるのか。」を中くらいに、残りを小さく組みます。文字の大きさと太さの差で、絵がなくても1枚ずつ違う顔になるようにしました。

【画像】LOG388_07_after_series5.png 作り直したあとの考察記事の一覧。どのカードも、記事の問いを明朝の文字で組んだサムネイルになっていて、「70万円」「成否」「差」「フィジカルAI」など問いの中の一語が大きく組まれている

最初から今の形だったわけではありません。問いを縦書きで1つ置くだけの形を12枚並べたところ、同じ形が続いて見分けにくかったので、組み方の違う案をいくつも作って、一覧に並べた画面で比べながら決めました。

| やったこと | なぜ | 結果 |
|---|---|---|
| サムネイルの方向を3つ出した（問い・対話・一語） | 他のシリーズの4コマや図と違う顔にするため | 記事の問いを置く形に決めた |
| 問いを縦書きで1つ置くだけの形を12枚並べた | 一覧で文字だけが並ぶと見分けにくいかを確かめるため | 同じ形が続いて単調だった |
| 一語を大きく・黒い面で分ける・文の強弱、の3案を作った | 文字の強弱と組み方で見分けがつくかを比べるため | 文の強弱（案3）にした |
| 案3の強弱の付け方を3通りにした | 差の大きさで印象がどう変わるかを比べるため | 3段階（3-B）にした |
| 余白・行の間・縦の列の中心・書体の読み込みを直した | 一覧に並べた実際の画面で、ずれや隙間が見えたため | 読み込みのたびに位置が変わる不具合もなくした |
| 67本の問いを書き、一覧・記事の上・SNS の画像を差し替えた | 考察記事すべてを同じ形にそろえるため | 最初に見える12枚の画像の重さが約10.2MBから約2.3MBになった |
| 記事の頭の2行からサムネイルを作る仕組みにした | 次の記事から絵を頼まずに済ませるため | 67本を書き出すのに0.06秒 |
| サイトのトップのシリーズのカードを直した | 日本語のサイトで英語が先に来ていたため | 日本語を先にし、説明も日本語に。考察記事のカードも問いの形に |

**絵をやめて問いを文字で組んだことで、考察記事のサムネイルは記事の中身から自動で決まるようになり、別の画面で頼む手間がなくなりました。** 次の考察記事からは、記事の頭に「問い」と「大きく見せる語」の2行を書くだけで、サイトに入れるときにサムネイルができます。

ここから下は、どの案をどう比べ、何を直したかの作業ログです。最後に、使ったコードと測った結果を全量で載せます。


## 作業ログ ― 3つの方向から、67本を差し替えてトップに出すまで

この記事の「見込める効果」のうち、重さと時間は実際に測った値です。読みに来た人が増えるかどうかは、まだ測っていない見込みです。比べた画像は、最後に決めた問いの文で書き出し直したものなので、作業の途中で見ていた画面と言い回しが少し違うところがあります。

### 考察記事のサムネイルは、1本ずつ Gemini に頼んだ4コマの絵だった

作り直す前の一覧です。4つの枠に、ロボットアームや道具のイラストと英語の見出しが入っています。描き方はそろっていて、一覧としての統一感はありました。困っていたのは見た目ではなく、作るたびに別の画面で頼む手間のほうでした。

【画像】LOG388_01_before_series5.png 作り直す前の考察記事の一覧。どのカードも、4つの枠にロボットアームや道具のイラストと英語の見出しを描いた絵になっている

### 方向は「問い・対話・一語」の3つから、問いを縦書きで置く形を選んだ

ほかのシリーズのサムネイルは、黒い地や紙の色の地に英語の見出しと図を置いたものが多く、トップの「ひとり研究所の裏側」には実物の画面を使ったものもあります。考察記事だけは違う顔にしたい、という希望だったので、最初に方向を3つ出しました。

| 案 | 主役 | 見本の中身 |
|---|---|---|
| A | 問い | 記事の問いを縦書きで1つ置く |
| B | 対話 | 問いと答えを1往復だけ置く（Q と A） |
| C | 一語 | 記事の鍵になる語を大きく置き、問いを小さく添える |

A を選んだのは、考察記事が「問いから始めて、AIとの対話で考える」記事だからです。カードの下にはタイトルが出るので、サムネイルには同じことを繰り返さず、タイトルとは別に「何を問うた記事か」を置く役目にしました。紙の色と明朝の書体は、考察記事のページの上にある「いま考えていること」のページへの入口（縦書きの問い「ロボットに、何を任せればいいのか。」）とそろえています。

### 縦書きの問いだけを12枚並べると、同じ形が続いて見分けにくかった

まず、一覧で最初に見える12本だけを差し替えた確認用のページを作りました。検索には出さない設定にして、どこからもリンクしていません。

最初は問いの長さに合わせて文字の大きさを変えていましたが、並べると大きさがばらばらに見えたので、全部同じ大きさにそろえ、1行を8字までにしました。それでも、12枚とも「紙の色の地に、縦書きの文字が右に寄っている」同じ形でした。

【画像】LOG388_02_first_trial_list.png 最初の試作を一覧に並べた画面。12枚とも紙の色の地に、問いを縦書きで右に寄せて置いた同じ形になっている

> 流石に単調だね。
> レイアウトを色々組んだり、文字の強弱でグラフィカルにみせる案を作ってみて

### 一語を大きく・黒い面・文の強弱の3案を作り、文の強弱を選んだ

どの案も、組み方を記事ごとに4通り（縦書きで右寄せ・横書きで左下・縦書きで左寄せ・横書きで右上）順に回すようにしました。一覧で同じ形が隣に続かないようにするためです。色は紙と墨の2色だけにしています。

| 案 | 組み方 | 並べたときの印象 |
|---|---|---|
| 案1 一語を大きく | 問いの中の一語を絵のように大きく置き、問いは小さく添える。一語はカードの端からはみ出すこともある | 文字の強さはいちばん大きい |
| 案2 黒い面で分ける | カードを黒い面と紙の面に分け、一語を黒い面に白で抜く | 一覧でいちばん目に入る |
| 案3 文の強弱 | 問いの文をそのまま組み、一語だけを大きくする | 文として読める。整い方はいちばんゆるい |

【画像】LOG388_03_four_plans.png 最初の試作・案1・案2・案3で、同じ4つの問いを組んで並べた比較。案1は一語が大きくはみ出し、案2は黒い面に白い文字、案3は問いの文の中で一語だけが大きい

選ばれたのは案3でした。続けて、強弱の付け方だけを変えた案を頼まれました。

> 案3で、フォントの強弱の差の別パターンを3つ用意して

### 強弱の付け方を3通りに変え、3段階の 3-B に決めた

組み方（位置と縦・横の回し方）は案3のままにして、文字の大きさと太さの付け方だけを変えました。

| 案 | 大きい一語 | 問いの結びの行 | 残り |
|---|---|---|---|
| 3-A 差を控えめに | 今の案3の半分ほど・太字 | 残りと同じ | 大きめ・ふつうの太さ |
| 3-B 3段階 | いちばん大きく・極太 | 中くらい・太字 | 小さく・細め |
| 3-C 太さだけで差 | 残りと同じ大きさ・極太 | 残りと同じ | 同じ大きさ・極細 |

3-C は、最初は大きさの差を少し残していました。並べると 3-A とほとんど同じに見えたので、大きさの差をなくし、太さだけで差をつける形に作り直しています。3つの違いがはっきりしないと、比べる意味がないためです。

【画像】LOG388_04_three_weights.png 3-A・3-B・3-C で同じ4つの問いを組んだ比較。3-A は一語が控えめ、3-B は一語・結びの行・残りの3段階、3-C は大きさが同じで一語だけが極太

12枚並べたときの強さは 3-B、落ち着きは 3-C が上でした。決まったのは 3-B です。

- ねらい：一語で目を止め、問いの結びの行まで目を流して、問い全体を読ませる
- 変わったこと：文字の大きさが3段階になり、問いの最後（「請けられるのか。」「覚えられるのか。」）が太字で残るようになった
- 見込める効果：一覧で目に入る語と、記事が問うていることの両方が、タイトルを読む前に伝わる（読みに来た人が増えたかは、考察記事のページの押された数で確かめる）

### 3-B で直した4つ：余白、行の間、大きく見せる語、縦の列の中心

3-B に決めたあと、一覧に並べた実際の画面を見ながら、1つずつ直しました。

> 3-Bで、余白をもう少し減らしたい

外側の余白を、組みによって違っていた幅（カードの幅の5〜7%）から 4.5% にそろえ、3段階の文字を全部ひと回り大きくしました。

> ここの隙間が空きすぎかな？

大きい一語がある行は、その一語の大きさに合わせて行の高さ（縦書きでは列の幅）が広がります。そのため、前後の行との間が開いていました。一語の行の高さを文字の1.12倍、問いの結びの行を1.25倍に詰めました。これは 3-B だけでなく、案3の4つすべてに入れています。

> フィジカルAIが大きい文字に変更

LOG324 の大きく見せる語を「AI」から「フィジカルAI」に変えました。長い語なので、後ろの「にあるものは何か。」と同じ行に置くと小さくなります。そこで、大きく見せる語が上限の大きさの8割に届かないときは、その語の後ろで改行する決まりを足しました（「フィジカルAIに／あるものは何か。」）。

> この辺修正（テキストセンタリング合わせ）
> 4万円は大きくていいよ

縦書きで、大きい一語と同じ列に並ぶ小さい文字（「の教材と、」「は」）が、列の中心から右に寄っていました。文字の大きさが違うものを同じ列に並べると、小さい文字が列の端にそろうためです。大きさを指定した文字だけを列の中心にそろえました。あわせて、縦書きの「AI」は1字の幅に収まらず列からはみ出していたので、英字は1字ずつ縦に立てて組み、数字（70・4）だけを縦の1字分に横に並べる形にしています。「4万円」も、横書きと同じ改行の決まりを縦書きにも広げて大きくしました（「4万円の／教材と、」）。

### 読み込むたびに数字の位置が変わったのは、書体が届く順番のせいだった

> 読み込むごとにセンタリングが変わるみたいだね

同じページを読み込み直すと、「70万円」の「70」の位置が変わっていました。

【画像】LOG388_05_reload_shift.png 同じ一覧を2回読み込んだ画面の比較。上と下で、縦書きの「70万円」の「70」の位置と、隣の小さい文字との間が変わっている

**原因は、書体の届く順番でした。** 明朝の書体（Noto Serif JP）は、文字ごとに細かく分かれて届きます。届くまでの間、一部の文字は Mac に入っている別の明朝で並びます。縦書きの数字は、そのときの書体で幅が決まったまま残ることがあり、読み込むたびに位置が変わっていました。

書体がすべて届くまで文字を出さず、届いてから組み直して出すようにしました。届かないときも4秒で出します。同じサムネイル5本を、新しいブラウザで5回ずつ読み込み直して画面を比べ、5本とも5回とも同じ画面になることを確かめています。

- ねらい：開くたびに見え方が変わらないようにする
- 変わったこと：書体が届くまで文字を出さない。届いたら組み直して出す
- 見込める効果：測った値で、5本×5回の読み込みが、どれも1通りの画面だけになった（直す前は、読み込むたびに数字の位置が変わっていた）

### 67本の問いを書き、語の途中で改行しない決まりを足した

> OK！これで参考記事全部のサムネイル変更。

確認用の12本以外の55本は、記事の冒頭を読んで問いと大きく見せる語を書きました。全部を並べて見たところ、改行の決まりが語の途中で切っていたものがありました（「欲しい」の後ろの「ロ／ボット」、「評価」の後ろの「す／る」など）。改行するのは「大きく見せる語＋助詞1字（の・に・は・を・で・が・と・も）」の後ろだけにし、残りが3字未満なら切らない決まりに直しました。それでも読みにくかった6本は、問いの区切り方を書き直しています。

【画像】LOG388_06_all_67.png 差し替えた考察記事67本のサムネイルを8列に並べた一覧。縦書きと横書き、右寄せと左寄せが順に入れ替わり、どれも問いの中の一語が大きい

### 一覧・記事の上・SNS の画像を、考察記事ならこのサムネイルにした

これまでの考察記事は、Gemini の絵を「表紙の画像」として持っていて、一覧・記事の上・SNS に貼ったときの画像のすべてにその絵が出ていました。考察記事のときだけ、表紙の画像より新しいサムネイルを使うようにし、SNS 用の画像（1200×675）も67本書き出しました。

- ねらい：考察記事の顔を、一覧・記事の上・SNS のどこでも同じにする
- 変わったこと：Gemini の絵（1枚平均約259KB、最大約2.9MB）が、問いの文字のサムネイル（1枚平均約2.4KB の HTML）に替わった
- 見込める効果：測った値で、考察記事の一覧を開いて最初に見える12枚は、これまで画像だけで約10.2MBだった。作り直したあとは、サムネイルの HTML が約0.1MB と書体のファイルが約2.2MB で、合わせて約2.3MB。書体のファイルは、同じページの文字と入口のバナーとも分け合っている

### 次の考察記事からは、記事の頭の2行でサムネイルができる

記事をサイトに入れる道具（`tools/add_article.py`）が、考察記事のときは記事の頭の `question:`（問い）と `key:`（大きく見せる語）を読んで、サムネイルを書き出すようにしました。2行がなければ止まって、書くように知らせます。組み方は記事ごとに固定し、新しい記事は一覧で隣になる記事の続きの組みが自動で付きます。すでにあるサムネイルの見え方は変わりません。試しに架空の LOG999 を足して、組みが前の記事の続きになることと、元に戻したあと LOG375 のサムネイルが1字も変わっていないことを確かめました。

- ねらい：考察記事を書くたびに、別の画面で絵を頼む手間をなくす
- 変わったこと：サムネイルは記事の頭の2行から作る。Gemini に頼む工程がなくなった
- 見込める効果：測った値で、67本のサムネイルを書き出すのに0.06秒、SNS 用の画像は10本で約33秒。これまでは、頼んで確かめて直すまでに1本5〜10分かかっていた

### サイトのトップのシリーズのカードを、日本語を先にして説明も日本語にした

> TOPのサムネイルも提案ください。
> それとTOPの日本語と英語のタイトルの位置を前後ろ入れ替え（他のシリーズも！）

トップの「シリーズ」に並ぶ7枚のカードは、英語の名前（Deep Dive）が大きく先に、日本語（考察記事）が小さく後ろにありました。シリーズのページの見出しは日本語が先なので、それにそろえて「考察記事 Deep Dive」の順にし、日本語を大きくしました。

> TOPのシリーズのセクションの各カードの説明文が英語なので、日本語に直してください（全カード修正）

説明文も英語だったので、7枚とも日本語にしました。各シリーズのページの説明を短くまとめたものです。

考察記事のカードの絵は、3つ出しました。A は「いま考えていること」のページと同じ問い「ロボットに、何を任せればいいのか。」を 3-B の形で組んだもの、B は記事ごとの大きく見せる語を大きさを変えて敷き詰めたもの、C は新しい4本のサムネイルを2×2で並べたものです。選ばれたのは A でした。

【画像】LOG388_08_top_series_cards.png 作り直したあとのトップのシリーズのカード7枚。日本語の名前が先に大きく、説明も日本語。考察記事のカードは縦書きの問い「ロボットに、何を任せればいいのか。」になっている

トップのカードは画像で表示しているので、問いを組んだページを画像に書き出して載せました。すぐ上にある「いま考えていること」のページへの入口と同じ紙の色と書体なので、ひと続きに見えます。

### 7通りの組み方を、同じ4つの問いで切り替えて比べる

この記事で比べた7通りの組み方を、同じ4つの問いで切り替えられるようにしました。

【図解】LOG388_seven_layouts.html

### 作業の流れ（サイトの commit の一覧）

確認用のページを作ってから、トップのカードを差し替えるまでの commit です。時刻は日本時間。

```text
ea180fa 11:19 確認用：考察記事のサムネイルを問いの縦書きにした一覧（/preview/deep-dive-thumbs）
6808459 11:26 確認用：考察記事のサムネイルに3案（一語を大きく・黒い面で分ける・文の強弱）を足した
9dcae55 11:31 確認用：案3（文の強弱）の強弱の付け方を3つ足した（差を控えめに・3段階・太さだけで差）
0599ecd 11:35 確認用：3-B（3段階）の余白を詰め、文字を大きくした
831b403 11:41 確認用：案3（4つとも）の大きい一語と問いの結びの行の、行の間を詰めた
dec65b8 11:47 確認用：LOG324 の強く見せる語を「フィジカルAI」にした
6478dcf 11:52 確認用：縦書きのサムネイルで、大きさの違う文字を列の中心にそろえた
432058f 11:53 確認用：サムネイルの見え方が読み込むたびに変わるのを直し、縦書きの列の中心をそろえた
5a9344e 12:11 考察記事67本のサムネイルを、問いを文字で組んだ形（3-B）に差し替えた。TOP のシリーズのカードは日本語を先に
787fe12 12:14 TOP のシリーズのカードの説明文を日本語にした（全7枚）
f90e771 12:17 TOP のシリーズのカード（考察記事）の絵を、案A「ロボットに、何を任せればいいのか。」にした
```


## 作業ログ（全量）― コード・データ・測った結果

ここから下は、この記事で使ったコードと、書き出したデータ、測った結果の全量です。

### コード：tools/deep_dive_thumb.py（サムネイルを書き出す道具）

問いと大きく見せる語から、7通りの組み方のサムネイルを書き出す道具の全文です。採用した 3-B は `p3b`、書体が届くまで文字を出さない処理は `HEAD` の中の script です。

```python
"""考察記事のサムネイル（問いを文字で組んだ形）を HTML で書き出す。

考察記事は Gemini の絵（4コマ）ではなく、記事の問いを文字で組んだサムネイルにする（2026-10-26 から。採用は 3-B「3段階」）。
問いと強く見せる一語の正は tools/deep_dive_thumbs.json（記事1本＝1項目）。
  question : 記事の問い。縦書きで改行したい所に「/」。1行9字まで（数字の並びは1字、英字は1字ずつ数える）
  key      : 強く見せる一語。問いの1行の中にある語
  layout   : 組み（0 縦・右寄せ／1 横・左下／2 縦・左寄せ／3 横・右上）。記事ごとに固定し、新しい記事が増えても既存の見え方は変えない

使い方:
  python3 tools/deep_dive_thumb.py                          # 全部の考察記事のサムネイルを public/thumbs/LOG{N}.html に書き出す
  python3 tools/deep_dive_thumb.py --add <LOG番号> "<問い>" "<一語>"   # 新しい考察記事を足して書き出す（組みは前の記事の続きを自動で決める）
  python3 tools/deep_dive_thumb.py --batch <JSON> [--plan p3b]         # 確認用：案ごとに書き出す（出力先は JSON の "out"/{案}/）
書き出したあと、SNS・検索結果用の画像は python3 tools/render_og.py LOG{N}（クラウドの作業環境で）。

案（組み方）。確認用ページ /preview/deep-dive-thumbs で比べたもの
  plain : 問いを縦書きで1つ置くだけ（最初の試作）
  p1    : 一語を大きく。問いの中の一語を絵のように大きく置き、問いは小さく添える
  p2    : 黒い面で分ける。黒い面に一語を白で抜き、紙の面に問いを置く
  p3    : 文の強弱。問いの文をそのまま組み、一語だけ大きく、残りは小さくする
  p3a   : 案3の強弱を控えめに（一語は今の半分ほど、文は大きめ）
  p3b   : 案3を3段階に（一語・問いの結び・残り）← 採用
  p3c   : 案3を太さだけで差をつける（大きさは全部同じ、一語は極太・残りは極細）

色は紙と墨の2色だけ。書体は明朝（考えのページ /thinking とバナー ThinkingBanner にそろえる）。
"""
import html
import json
import re
import sys
from pathlib import Path

PAPER = "#f5f3ee"
INK = "#22201c"
MAX_CHARS = 9  # 英字を縦に1字ずつ立てたので「フィジカルAIは、」が9字になる
PARTICLES = set("のにはをでがとも")  # 一語の後ろで改行してよい助詞
PLANS = ["plain", "p1", "p2", "p3", "p3a", "p3b", "p3c"]

HEAD = """<!doctype html>
<html lang="ja" class="wait">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LOG{log} thumbnail</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@200;300;400;700;900&display=block" rel="stylesheet">
<style>
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  html, body {{ background: {paper}; }}
  .wait .wrap > * {{ visibility: hidden; }}
  .wrap {{ width: 100%; aspect-ratio: 16 / 9; container-type: inline-size; position: relative; overflow: hidden; background: {paper}; color: {ink};
    font-family: "Noto Serif JP", "Hiragino Mincho ProN", "Yu Mincho", serif; }}
  .v {{ writing-mode: vertical-rl; white-space: nowrap; }}
  .h {{ white-space: nowrap; }}
  .tcy {{ text-combine-upright: all; letter-spacing: 0; }}
  .up {{ text-orientation: upright; }}
  .abs {{ position: absolute; }}
  .blk {{ position: absolute; background: {ink}; color: {paper}; }}
  .key {{ font-weight: 900; line-height: 1; letter-spacing: 0; }}
  .sm {{ font-weight: 400; letter-spacing: .06em; }}
  .b {{ font-weight: 900; }}
{css}
</style>
</head>
<body>
<div class="wrap">{body}</div>
<script>
  // 書体がすべて届いてから組み直して出す。日本語の書体は文字ごとに分かれて届くため、途中は別の明朝で並び、
  // 縦書きの数字（縦中横）の幅が最初の書体のまま残って、読み込むたびに位置が変わっていた（2026-10-26）
  (function () {{
    var done = false;
    function show() {{
      if (done) return; done = true;
      var w = document.querySelector('.wrap');
      w.style.display = 'none'; void w.offsetHeight; w.style.display = '';
      document.documentElement.classList.remove('wait');
    }}
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () {{ requestAnimationFrame(show); }});
    else show();
    setTimeout(show, 4000);  // 書体が届かないときも4秒で出す
  }})();
</script>
</body>
</html>
"""


def vlen(s: str) -> float:
    """縦に並べたときの長さ（字数）。縦中横にする1〜3字の数字は1字に数え、英字は1字ずつ数える"""
    return len(re.sub(r"[0-9]{1,3}", "x", s))


def hlen(s: str) -> float:
    """横に並べたときの長さ（字数）。英数字は半分強の幅で数える"""
    return sum(0.6 if re.match(r"[A-Za-z0-9]", ch) else 1 for ch in s)


def esc(s: str, vertical: bool) -> str:
    s = html.escape(s)
    if vertical:
        # 英字（AI など）は1字の幅に収まらず列からはみ出すので、縦に1字ずつ立てて組む
        s = re.sub(r"([A-Za-z]+)", r'<span class="up">\1</span>', s)
        # 縦書きの中で、（英字の後に入れる。先に入れると、印の中の英字まで囲んでしまう）1〜3字の数字（4・70 など）は横に倒さず、横に並べて1字分に組む（縦中横）
        s = re.sub(r"(?<![0-9])([0-9]{1,3})(?![0-9])", r'<span class="tcy">\1</span>', s)
    return s


def lines_of(q: str) -> list[str]:
    return [l for l in q.split("/") if l]


def hlines(q: str) -> list[str]:
    """横書きのときの行。縦書き用の短い行を2つずつつなぐ"""
    ls = lines_of(q)
    return ["".join(ls[i:i + 2]) for i in range(0, len(ls), 2)]


def small_v(q: str) -> str:
    return "<br>".join(esc(l, True) for l in lines_of(q))


def small_h(q: str) -> str:
    return "<br>".join(esc(l, False) for l in hlines(q))


# ── plain：問いを縦書きで1つ ──────────────────────────────
def plain(it, n):
    css = "  .q { right: 10cqw; top: 6.5cqw; font-size: 5.3cqw; line-height: 1.75; letter-spacing: .08em; font-weight: 700; }"
    return css, f'<p class="abs v q">{small_v(it["question"])}</p>'


# ── p1：一語を大きく ─────────────────────────────────────
def p1(it, n):
    key, q = it["key"], it["question"]
    kv = round(min(22, 46 / vlen(key)), 2)   # 縦に置くときの大きさ（高さ 56.25cqw に収める）
    kh = round(min(26, 84 / hlen(key)), 2)   # 横に置くときの大きさ（幅 100cqw に収める）
    s = 3.3
    lay = n % 4
    if lay == 0:   # 一語を縦に右端、問いを縦にその左
        css = f"""  .k {{ right: 7cqw; top: 4.5cqw; font-size: {kv}cqw; }}
  .s {{ right: {7 + kv + 5}cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}"""
        body = f'<p class="abs v key k">{esc(key, True)}</p><p class="abs v sm s">{small_v(q)}</p>'
    elif lay == 1:  # 一語を横に下へ大きく（少し下にはみ出す）、問いを横に左上
        css = f"""  .k {{ left: 4.5cqw; bottom: -{kh * 0.12:.2f}cqw; font-size: {kh}cqw; }}
  .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.8; }}"""
        body = f'<p class="abs h key k">{esc(key, False)}</p><p class="abs h sm s">{small_h(q)}</p>'
    elif lay == 2:  # 一語を横に右上、問いを縦に左
        css = f"""  .k {{ right: 5cqw; top: 5cqw; font-size: {min(kh, 22)}cqw; text-align: right; }}
  .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}"""
        body = f'<p class="abs h key k">{esc(key, False)}</p><p class="abs v sm s">{small_v(q)}</p>'
    else:           # 一語を縦に左、問いを横に右下
        css = f"""  .k {{ left: 6cqw; top: 4.5cqw; font-size: {kv}cqw; }}
  .s {{ right: 6cqw; bottom: 6cqw; font-size: {s}cqw; line-height: 1.8; text-align: right; }}"""
        body = f'<p class="abs v key k">{esc(key, True)}</p><p class="abs h sm s">{small_h(q)}</p>'
    return css, body


# ── p2：黒い面で分ける ───────────────────────────────────
def p2(it, n):
    key, q = it["key"], it["question"]
    s = 3.4
    lay = n % 4
    if lay == 0:   # 黒い面を右に縦長、一語は縦
        w = 36
        kv = round(min(19, 44 / vlen(key), w * 0.62), 2)
        css = f"""  .bk {{ right: 0; top: 0; bottom: 0; width: {w}cqw; display: flex; align-items: center; justify-content: center; }}
  .k {{ font-size: {kv}cqw; }}
  .s {{ right: {w + 7}cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}"""
        body = f'<div class="blk bk"><p class="v key k">{esc(key, True)}</p></div><p class="abs v sm s">{small_v(q)}</p>'
    elif lay == 1:  # 黒い面を下に帯、一語は横
        hgt = 25
        kh = round(min(17, 84 / hlen(key)), 2)
        css = f"""  .bk {{ left: 0; right: 0; bottom: 0; height: {hgt}cqw; display: flex; align-items: center; padding-left: 6cqw; }}
  .k {{ font-size: {kh}cqw; }}
  .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.8; }}"""
        body = f'<div class="blk bk"><p class="h key k">{esc(key, False)}</p></div><p class="abs h sm s">{small_h(q)}</p>'
    elif lay == 2:  # 黒い面を左に、一語は縦。問いは右に縦
        w = 32
        kv = round(min(17, 44 / vlen(key), w * 0.6), 2)
        css = f"""  .bk {{ left: 0; top: 0; bottom: 0; width: {w}cqw; display: flex; align-items: center; justify-content: center; }}
  .k {{ font-size: {kv}cqw; }}
  .s {{ right: 8cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}"""
        body = f'<div class="blk bk"><p class="v key k">{esc(key, True)}</p></div><p class="abs v sm s">{small_v(q)}</p>'
    else:           # 黒い面を右上に、一語は横。問いは左下に横
        w, hgt = 46, 30
        kh = round(min(15, (w - 8) / hlen(key)), 2)
        css = f"""  .bk {{ right: 0; top: 0; width: {w}cqw; height: {hgt}cqw; display: flex; align-items: center; justify-content: center; }}
  .k {{ font-size: {kh}cqw; }}
  .s {{ left: 6cqw; bottom: 6cqw; font-size: {s}cqw; line-height: 1.8; }}"""
        body = f'<div class="blk bk"><p class="h key k">{esc(key, False)}</p></div><p class="abs h sm s">{small_h(q)}</p>'
    return css, body


# ── p3：文の強弱 ─────────────────────────────────────────
# 強弱の付け方（大きさと太さ）だけを変えた型。組み（位置と縦・横）は4通りを順に回す
#   small: 小さい文字の大きさ（縦, 横）／ big: 一語の大きさの上限 ／ w: 太さ（一語, 残り）
#   mid: 問いの結びの行を中くらいにする（大きさ, 太さ）。None なら2段階
P3_STYLES = {
    "p3":  dict(small=(3.6, 3.8), big=(13, 15), w=(900, 400), mid=None),     # 今の案3：一語を大きく、残りを小さく
    "p3a": dict(small=(4.4, 4.6), big=(7.5, 8.5), w=(700, 400), mid=None),   # 差を控えめに：一語は今の半分ほど、文は大きめ
    "p3b": dict(small=(4.2, 4.4), big=(16, 18), w=(900, 300), mid=(6.2, 700), pad=4.5),  # 3段階：一語・問いの結び・残り。余白を詰め、文字を大きく
    "p3c": dict(small=(5.2, 5.0), big=(5.2, 5.0), w=(900, 200), mid=None),   # 太さだけで差：大きさは全部同じ、一語は極太・残りは極細
}


def p3_style(name):
    st = P3_STYLES[name]

    def render_p3(it, n):
        key, q = it["key"], it["question"]
        lay = n % 4
        vertical = lay in (0, 2)
        ls = lines_of(q) if vertical else hlines(q)
        if not any(key in l for l in ls):
            raise SystemExit(f"LOG{it['log']}: key「{key}」が問いの行の中で切れています。/ の位置を直してください")
        small = st["small"][0 if vertical else 1]
        L = vlen if vertical else hlen
        pad = st.get("pad")                    # 外側の余白（cqw）。None なら案3の標準
        room = (56.25 - pad * 2 if pad else 46) if vertical else (100 - pad * 2 if pad else 86)  # 1行が使える長さ
        k = 1.06 if vertical else 1.0
        cap = st["big"][0 if vertical else 1]

        def fit(ls):
            ki = next(i for i, l in enumerate(ls) if key in l)
            rest = ls[ki].replace(key, "", 1)
            return ki, round(min(cap, (room - L(rest) * small * k) / (L(key) * k)), 2)

        ki, big = fit(ls)
        if big < cap * 0.8:
            # 一語が上限の大きさまで届かないときは、一語の行を短くするため改行する（縦書き・横書きとも）
            # 切るのは「一語＋助詞1字（＋読点）」の後ろだけ。語の途中で切らない。残りが3字未満なら切らない
            # 例：フィジカルAIにあるものは何か。 → フィジカルAIに／あるものは何か。　4万円の教材と、 → 4万円の／教材と、
            a, b = ls[ki].split(key, 1)
            if b[:1] in PARTICLES:
                n = 2 if b[1:2] == "、" else 1
                head, tail = key + b[:n], b[n:]
                if len(tail) >= 3:
                    ls = ls[:ki] + [x for x in (a + head, tail) if x] + ls[ki + 1:]
                    ki, big = fit(ls)
        # 中くらいにする行：問いの結び（最後の行）。一語がその行にあれば最初の行
        mi = None
        if st["mid"] and len(ls) > 1:
            mi = len(ls) - 1 if ki != len(ls) - 1 else 0
        wb, ws = st["w"]
        # 大きい一語と中くらいの行は、その文字の大きさに合わせて行（縦書きでは列）が広がり、前後の行との間が開く。行の高さを詰める
        lb, lm = "line-height:1.12;", "line-height:1.25;"

        def line(i, l):
            if i == ki:
                a, b = l.split(key, 1)
                return f'{esc(a, vertical)}<span style="font-size:{big}cqw;font-weight:{wb};{lb}">{esc(key, vertical)}</span>{esc(b, vertical)}'
            if i == mi:
                ms = round(min(st["mid"][0], room / (L(l) * k)), 2)
                return f'<span style="font-size:{ms}cqw;font-weight:{st["mid"][1]};{lm}">{esc(l, vertical)}</span>'
            return esc(l, vertical)

        inner = "<br>".join(line(i, l) for i, l in enumerate(ls))
        pv, ph = (pad, pad) if pad else (5, 6)  # 縦の行の上の余白、横の行の端の余白
        pside = pad + 1.5 if pad else 7        # 縦書きの端。大きい英数字（70 など）が縦中横で幅を取るので、横書きより少し広く
        pos = {0: f"right: {pside}cqw; top: {pv}cqw;",                        # 縦、右寄せ
               1: f"left: {ph}cqw; bottom: {ph}cqw;",                         # 横、左下
               2: f"left: {pside}cqw; top: {pv}cqw;",                         # 縦、左寄せ
               3: f"right: {ph}cqw; top: {ph}cqw; text-align: right;"}[lay]  # 横、右上・右揃え
        css = f"  .t {{ {pos} font-size: {small}cqw; line-height: 1.55; letter-spacing: .04em; font-weight: {ws}; }}"
        if vertical:
            # 縦書きで大きさの違う文字が同じ列に並ぶと、小さい文字が列の右に寄る。列の中心にそろえる
            css += "\n  .t > span[style] { vertical-align: middle; }"
        return css, f'<p class="abs {"v" if vertical else "h"} t">{inner}</p>'

    return render_p3


RENDER = {"plain": plain, "p1": p1, "p2": p2, **{k: p3_style(k) for k in P3_STYLES}}


def render(it: dict, plan: str, n: int) -> str:
    longest = max(vlen(l) for l in lines_of(it["question"]))
    if longest > MAX_CHARS:
        raise SystemExit(f"LOG{it['log']}: 1行が{longest:g}字あります。8字までになるよう「/」で区切ってください")
    css, body = RENDER[plan](it, n)
    return HEAD.format(log=it["log"], paper=PAPER, ink=INK, css=css, body=body)


SITE = Path(__file__).resolve().parent.parent
DATA = SITE / "tools/deep_dive_thumbs.json"
ADOPTED = "p3b"


def main(argv):
    if argv and argv[0] == "--batch":
        conf = json.loads(Path(argv[1]).read_text())
        plans = [argv[argv.index("--plan") + 1]] if "--plan" in argv else PLANS
        for plan in plans:
            out = Path(conf["out"]) / plan
            out.mkdir(parents=True, exist_ok=True)
            for n, it in enumerate(conf["items"]):
                (out / f"LOG{it['log']}.html").write_text(render(it, plan, it.get("layout", n)))
            print(plan, len(conf["items"]))
        return
    conf = json.loads(DATA.read_text())
    items = conf["items"]
    if argv and argv[0] == "--add":
        log, question, key = int(argv[1]), argv[2], argv[3]
        items = [it for it in items if it["log"] != log]
        newest = max(items, key=lambda it: it["log"])
        # 一覧は LOG の大きい順に並ぶ。いちばん新しい記事の組みの1つ前を使うと、並んだときに4通りが順に回る
        items.append({"log": log, "question": question, "key": key, "layout": (newest["layout"] - 1) % 4})
        items.sort(key=lambda it: -it["log"])
        conf["items"] = items
        render(items[0] if items[0]["log"] == log else next(it for it in items if it["log"] == log), ADOPTED, 0)  # 1行の字数などを先に確かめる
        DATA.write_text(json.dumps(conf, ensure_ascii=False, indent=2) + "\n")
    out = SITE / "public/thumbs"
    out.mkdir(parents=True, exist_ok=True)
    for it in items:
        (out / f"LOG{it['log']}.html").write_text(render(it, ADOPTED, it["layout"]))
    print(f"書き出し {len(items)} 件 → public/thumbs/")


if __name__ == "__main__":
    main(sys.argv[1:])
```

### データ：tools/deep_dive_thumbs.json（67本の問いと大きく見せる語）

考察記事67本の問いと大きく見せる語、組み（layout：0 縦・右寄せ／1 横・左下／2 縦・左寄せ／3 横・右上）の全量です。サムネイルの正はこのファイルです。

```json
{
  "items": [
    {
      "log": 375,
      "question": "70万円の/アームで、/仕事を/請けられるのか。",
      "key": "70万円",
      "layout": 0
    },
    {
      "log": 336,
      "question": "成否だけで、/組立は/覚えられるのか。",
      "key": "成否",
      "layout": 1
    },
    {
      "log": 329,
      "question": "アームを/買うなら、/どこで/差をつけるのか。",
      "key": "差",
      "layout": 2
    },
    {
      "log": 324,
      "question": "産業用ロボットに/なくて、/フィジカルAIに/あるものは何か。",
      "key": "フィジカルAI",
      "layout": 3
    },
    {
      "log": 323,
      "question": "実演は、/何本集めれば/いいのか。",
      "key": "何本",
      "layout": 0
    },
    {
      "log": 320,
      "question": "AIに、/ロボットを/直接/動かさせるか。",
      "key": "直接",
      "layout": 1
    },
    {
      "log": 319,
      "question": "AIを/変えずに、/検査は/良くなるのか。",
      "key": "検査",
      "layout": 2
    },
    {
      "log": 318,
      "question": "専用機が/ある仕事を、/ロボットに/させるのか。",
      "key": "専用機",
      "layout": 3
    },
    {
      "log": 316,
      "question": "4万円の教材と、/自動運転は、/何が同じか。",
      "key": "4万円",
      "layout": 0
    },
    {
      "log": 315,
      "question": "フィジカルAIは、/人の形で/来るのか。",
      "key": "人の形",
      "layout": 1
    },
    {
      "log": 314,
      "question": "導入が/進まないのは、/機体の/せいか。",
      "key": "機体",
      "layout": 2
    },
    {
      "log": 312,
      "question": "計算資源の/そばで、/何のデータを/取るのか。",
      "key": "計算資源",
      "layout": 3
    },
    {
      "log": 306,
      "question": "現場の/どこで/ロボットは/止まるのか。",
      "key": "止まる",
      "layout": 0
    },
    {
      "log": 296,
      "question": "ロボットの/柔らかさを、/どこで/作るのか。",
      "key": "柔らかさ",
      "layout": 1
    },
    {
      "log": 292,
      "question": "作業しない/ロボットは、/役に/立てるのか。",
      "key": "作業しない",
      "layout": 2
    },
    {
      "log": 291,
      "question": "人型を追わずに、/用途を/固定して/勝てるか。",
      "key": "用途",
      "layout": 3
    },
    {
      "log": 290,
      "question": "自動運転の/地図は、/どこまで/減らせるか。",
      "key": "地図",
      "layout": 0
    },
    {
      "log": 286,
      "question": "62万円のアームは、/なぜ/町工場に/入らないのか。",
      "key": "町工場",
      "layout": 1
    },
    {
      "log": 282,
      "question": "賢さを/モデルに積むか、/環境を/作り込むか。",
      "key": "環境",
      "layout": 2
    },
    {
      "log": 278,
      "question": "精度は、/モデルの外で/作れるのか。",
      "key": "精度",
      "layout": 3
    },
    {
      "log": 276,
      "question": "ロボットを/箱に/閉じ込める/理由は何か。",
      "key": "箱",
      "layout": 0
    },
    {
      "log": 264,
      "question": "欲しい/ロボットは、/ロボットから/探せるのか。",
      "key": "欲しい",
      "layout": 1
    },
    {
      "log": 262,
      "question": "身体を持つと、/AIとの/関係は/変わるか。",
      "key": "身体",
      "layout": 2
    },
    {
      "log": 260,
      "question": "ロボットの/脳と筋肉を、/どこで/分けるか。",
      "key": "脳と筋肉",
      "layout": 3
    },
    {
      "log": 249,
      "question": "骨格はコード、/判断は/AIに/任せるか。",
      "key": "判断",
      "layout": 0
    },
    {
      "log": 245,
      "question": "街のロボットは、/誰の/責任かを/どう示すか。",
      "key": "責任",
      "layout": 1
    },
    {
      "log": 237,
      "question": "最後の/数メートルを、/誰が/引き受けるか。",
      "key": "最後",
      "layout": 2
    },
    {
      "log": 233,
      "question": "デモが/上手くなれば、/社会に/入るのか。",
      "key": "デモ",
      "layout": 3
    },
    {
      "log": 229,
      "question": "ロボットの/受け渡しで、/責任は/どこへ移るか。",
      "key": "受け渡し",
      "layout": 0
    },
    {
      "log": 223,
      "question": "正しい動きと、/いい動きは、/何が/違うのか。",
      "key": "いい動き",
      "layout": 1
    },
    {
      "log": 221,
      "question": "VLMを/使うなら、/VLAを/作るしかないか。",
      "key": "VLA",
      "layout": 2
    },
    {
      "log": 219,
      "question": "同じ/動き方を、/データと見るか、/体験と見るか。",
      "key": "体験",
      "layout": 3
    },
    {
      "log": 218,
      "question": "作る速さが/そろったとき、/何が/残るのか。",
      "key": "残る",
      "layout": 0
    },
    {
      "log": 214,
      "question": "コードが消えても、/設計は/残るのか。",
      "key": "設計",
      "layout": 1
    },
    {
      "log": 212,
      "question": "滑らかな/動きは、/人を/安心させるのか。",
      "key": "滑らか",
      "layout": 2
    },
    {
      "log": 207,
      "question": "ロボットの動きを、/体験として/設計できるか。",
      "key": "体験",
      "layout": 3
    },
    {
      "log": 206,
      "question": "色ではなく、/物を/見るには/どうするか。",
      "key": "物",
      "layout": 0
    },
    {
      "log": 205,
      "question": "単腕のまま、/まだ/やれることは/あるか。",
      "key": "単腕",
      "layout": 1
    },
    {
      "log": 201,
      "question": "安いアーム2台で、/高い1台に/勝てるか。",
      "key": "2台",
      "layout": 2
    },
    {
      "log": 198,
      "question": "タダで/使えるAIの、/本当の/値段は。",
      "key": "タダ",
      "layout": 3
    },
    {
      "log": 195,
      "question": "自分の文章の/クセを、/自分で/見抜けるか。",
      "key": "クセ",
      "layout": 0
    },
    {
      "log": 194,
      "question": "ロボットの/使いやすさを、/誰が/設計するのか。",
      "key": "使いやすさ",
      "layout": 1
    },
    {
      "log": 187,
      "question": "動きの体験を、/誰が/定義/するのか。",
      "key": "定義",
      "layout": 2
    },
    {
      "log": 181,
      "question": "動きの設計に、/関係の/時間を/加えられるか。",
      "key": "関係",
      "layout": 3
    },
    {
      "log": 174,
      "question": "日本で/フィジカルAIが/広がらないのは、/なぜか。",
      "key": "日本",
      "layout": 0
    },
    {
      "log": 167,
      "question": "自分の記録を、/他の人が/使える/形にできるか。",
      "key": "他の人",
      "layout": 1
    },
    {
      "log": 166,
      "question": "いま/深度カメラを/買うべきか。",
      "key": "深度カメラ",
      "layout": 2
    },
    {
      "log": 161,
      "question": "正解が/毎年変わる/現場で、/何を備えるか。",
      "key": "正解",
      "layout": 3
    },
    {
      "log": 159,
      "question": "上の/カメラ/だけで、/見えない物を/どう見るか。",
      "key": "カメラ",
      "layout": 0
    },
    {
      "log": 155,
      "question": "フィジカルAIの/安全は、/誰が/設計するのか。",
      "key": "安全",
      "layout": 1
    },
    {
      "log": 152,
      "question": "環境で/解くか、/AIで/解くか。",
      "key": "環境",
      "layout": 2
    },
    {
      "log": 147,
      "question": "考察記事の/サムネイルを、/どう/そろえるか。",
      "key": "サムネイル",
      "layout": 3
    },
    {
      "log": 145,
      "question": "技術が/できても、/社会は/動かないのか。",
      "key": "社会",
      "layout": 0
    },
    {
      "log": 143,
      "question": "デモを/動かすたびに、/データは/生まれるか。",
      "key": "データ",
      "layout": 1
    },
    {
      "log": 140,
      "question": "100万回の/学習を、/10回から/始められるか。",
      "key": "10回",
      "layout": 2
    },
    {
      "log": 135,
      "question": "ゲームから/借りるべきは、/形か、/思想か。",
      "key": "思想",
      "layout": 3
    },
    {
      "log": 128,
      "question": "作る役と/評価/する役を、/分けるべきか。",
      "key": "評価",
      "layout": 0
    },
    {
      "log": 126,
      "question": "実世界の/データだけで、/ロボットは/賢くなるか。",
      "key": "実世界",
      "layout": 1
    },
    {
      "log": 121,
      "question": "AIが読む時代に、/なぜ/ログを/書くのか。",
      "key": "ログ",
      "layout": 2
    },
    {
      "log": 119,
      "question": "ロボットは/なぜまだ/速さを/競うのか。",
      "key": "速さ",
      "layout": 3
    },
    {
      "log": 118,
      "question": "良い動きは、/データの/量で/決まるのか。",
      "key": "量",
      "layout": 0
    },
    {
      "log": 115,
      "question": "人はカップを、/精密に/掴んで/いるのか。",
      "key": "精密",
      "layout": 1
    },
    {
      "log": 112,
      "question": "動きの言葉を、/誰が/先に/決めるのか。",
      "key": "先に",
      "layout": 2
    },
    {
      "log": 110,
      "question": "書き続けることは、/何を/生むのか。",
      "key": "書き続ける",
      "layout": 3
    },
    {
      "log": 109,
      "question": "身体で/考えるとは、/どういう/ことか。",
      "key": "身体",
      "layout": 0
    },
    {
      "log": 107,
      "question": "関心はあるのに、/なぜ/作れる人が/いないのか。",
      "key": "作れる人",
      "layout": 1
    },
    {
      "log": 53,
      "question": "AI時代に、/デザインは/利益を/決めるのか。",
      "key": "利益",
      "layout": 2
    }
  ]
}
```

### 出力：書き出したサムネイルの1本（public/thumbs/LOG375.html）

道具が書き出すサムネイルの実物です。1本は約2.4KB の HTML で、画像を持ちません。

```html
<!doctype html>
<html lang="ja" class="wait">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LOG375 thumbnail</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@200;300;400;700;900&display=block" rel="stylesheet">
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { background: #f5f3ee; }
  .wait .wrap > * { visibility: hidden; }
  .wrap { width: 100%; aspect-ratio: 16 / 9; container-type: inline-size; position: relative; overflow: hidden; background: #f5f3ee; color: #22201c;
    font-family: "Noto Serif JP", "Hiragino Mincho ProN", "Yu Mincho", serif; }
  .v { writing-mode: vertical-rl; white-space: nowrap; }
  .h { white-space: nowrap; }
  .tcy { text-combine-upright: all; letter-spacing: 0; }
  .up { text-orientation: upright; }
  .abs { position: absolute; }
  .blk { position: absolute; background: #22201c; color: #f5f3ee; }
  .key { font-weight: 900; line-height: 1; letter-spacing: 0; }
  .sm { font-weight: 400; letter-spacing: .06em; }
  .b { font-weight: 900; }
  .t { right: 6.0cqw; top: 4.5cqw; font-size: 4.2cqw; line-height: 1.55; letter-spacing: .04em; font-weight: 300; }
  .t > span[style] { vertical-align: middle; }
</style>
</head>
<body>
<div class="wrap"><p class="abs v t"><span style="font-size:13.46cqw;font-weight:900;line-height:1.12;"><span class="tcy">70</span>万円</span>の<br>アームで、<br>仕事を<br><span style="font-size:5.57cqw;font-weight:700;line-height:1.25;">請けられるのか。</span></p></div>
<script>
  // 書体がすべて届いてから組み直して出す。日本語の書体は文字ごとに分かれて届くため、途中は別の明朝で並び、
  // 縦書きの数字（縦中横）の幅が最初の書体のまま残って、読み込むたびに位置が変わっていた（2026-10-26）
  (function () {
    var done = false;
    function show() {
      if (done) return; done = true;
      var w = document.querySelector('.wrap');
      w.style.display = 'none'; void w.offsetHeight; w.style.display = '';
      document.documentElement.classList.remove('wait');
    }
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { requestAnimationFrame(show); });
    else show();
    setTimeout(show, 4000);  // 書体が届かないときも4秒で出す
  })();
</script>
</body>
</html>
```

### コード：src/lib/records.ts（考察記事ならこのサムネイルを使う）

一覧のカードを作るところで、考察記事のときだけ表紙の画像（Gemini の絵）より新しいサムネイルを使う `withDeepDiveThumb` を足しました。

```ts
import { getCollection } from 'astro:content';
import cards from '../data/cards.json';
import mangaCards from '../data/mangaCards.json';
import englishCards from '../data/englishCards.json';
import deepDiveThumbs from '../../tools/deep_dive_thumbs.json';

// 考察記事のサムネイルは、問いを文字で組んだ形（public/thumbs/LOG{N}.html。tools/deep_dive_thumb.py で書き出す。2026-10-26 から）
// Gemini の絵（cover）より優先する。正は tools/deep_dive_thumbs.json
const DEEP_DIVE = new Set((deepDiveThumbs.items as { log: number }[]).map((i) => i.log));
export function withDeepDiveThumb<T extends { day: string; series: string; cover: string | null; thumb: string | null }>(c: T): T {
  const m = /^LOG(\d+)$/.exec(c.day);
  if (c.series !== '考察記事' || !m || !DEEP_DIVE.has(Number(m[1]))) return c;
  return { ...c, cover: null, thumb: `/thumbs/LOG${m[1]}.html` };
}

export type Card = { day: string; series: string; published: string; title: string; slug: string; cover: string | null; thumb: string | null };
export type MangaCard = { slug: string; title: string; label: string; character: string; description: string; published: string; cover: string; comic: string; cover_alt: string; comic_alt: string };

// 一覧に並べる記事：Claude が書いた Markdown の記事 ＋ Studio から移した記事
// 並び順は LOG 番号の大きい順（1日に何本も出すようになり、公開日時の順だと番号と前後することがあるため）。
// 番号が同じか取れないもの（英語版の LOG187EN など）は公開日時で並べる
export async function allCards(): Promise<Card[]> {
  const md = (await getCollection('records')).map((e) => ({
    day: `LOG${e.data.day}`, series: e.data.series, published: e.data.date.toISOString(),
    title: e.data.title, slug: e.data.slug, cover: e.data.cover ?? null, thumb: e.data.cover ? null : `/thumbs/LOG${e.data.day}.html`,
  }));
  const seen = new Set(md.map((c) => c.slug));
  const studio = (cards as Card[]).filter((c) => !seen.has(c.slug));
  const num = (c: Card) => { const m = /^LOG(\d+)/.exec(c.day); return m ? Number(m[1]) : -1; };
  return [...md, ...studio].map(withDeepDiveThumb).sort((a, b) => (num(b) - num(a)) || b.published.localeCompare(a.published));
}

export async function seriesCards(series: string): Promise<Card[]> {
  return (await allCards()).filter((c) => c.series === series);
}

export const allManga = mangaCards as MangaCard[];
export const allEnglish = englishCards as MangaCard[];

// 試算のタブ（シリーズのページの Simulator）。task は何をするロボットか、place はどこで使うか。
// 1つ目は sim.src・sim.title・sim.note・sim.obs をそのまま使う。2つ目からは src に # で現場を付ける
// img はタブに出す写真（public/site/sim/ に置く。なければ文字だけ）
export type SimTab = { id: string; task: string; place: string; src: string; title: string; note: string; img?: string };

// シリーズのページ（URL → 記事のシリーズ名）。Studio の一覧の絞り込み条件と同じ
// intro はシリーズのページの見出しの下に出す説明（ないシリーズは出さない）
export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; points?: { h: string; items: string[] }[]; guide?: boolean; thinking?: boolean; serving?: boolean; sim?: { src: string; title: string; note: string; obs?: { src: string; title: string; label: string; note: string; points: string[] }; tabs?: SimTab[] } }> = {
  series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
    intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり（HRI）を設計している記録です。病院で使う画面（UI）の設計、Unity での自律移動、Fusion での形づくり、分身ロボット（アバター）の考察までを、4コママンガと一緒に残しています。' },
  series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R（Real to Render to Real）', pageTitle: 'Series2 | R2R2R', limit: 12,
    intro: 'iPhone で物を3Dスキャンし、Unity の中で動きを試してから、机の上のロボットアーム（mechArm 270）で実際に動かす記録です。ロボット工学を専門に学んでいないデザイナーでも、アームのつかむ・渡すといった動きを設計できるかを、失敗も含めて残しています。' },
  series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20,
    intro: '湿気の多い日本の農業で、紫外線（UV-C）を当てて作物の病気を抑えるロボットが何を変えるかを考えた記録です。フィジカルAIを使った農業を、製造業のように管理する仕組みとして捉え直しています。' },
  series4: { series: 'ひとり研究所の裏側', title: 'Behind the Solo Lab | ひとり研究所の裏側', pageTitle: 'Series4 | ひとり研究所の裏側', limit: 12,
    intro: 'フィジカルAIの研究をひとりで続けるために、AIと一緒に作ってきた仕組みの記録です。サイトの移行、動画の制作、記録の整理など、研究の外側の作業をどう回しているかを残しています。' },
  series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12, thinking: true,
    intro: '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事です。論文や企業の発表、市場の動きを読み、対話の形で考えたことを、外れた議論も含めてそのまま残しています。' },
  series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12, guide: true,
    intro: 'ロボットアームが動きを覚える環境を、自宅にひとりで作っている記録です。机の上のアームでお手本の動きを集め、ロボット用のAI（Physical Intelligence の π0.5 など）に学習させ、実機で動かして確かめます。借りたGPUの種類・時間・費用も含めて、誰でも同じ手順で始められる形で残しています。' },
  series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12,
    intro: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直している記録です。画素の数字、色の見分け方、しきい値、輪郭、特徴点といった昔からの手法を、机の上のアームを撮った写真で1つずつ測って確かめ、その上で SAM 3 のような新しいモデルも試します。評価の物差し（IoU・適合率・再現率など）を先に決め、結果は数字で残しています。' },
  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12, serving: true,
    intro: 'サービスロボット導入シミュレーションとは、配膳・清掃・搬送などのロボットを店や施設に入れる前に、待ち時間・必要な人数・ロボットが止まる場所・費用を試算して確かめることです。このシリーズでは、時間・順番・台数を SimPy で、ぶつかる・こぼれるといった空間と動きを Unity で扱い、結果とコードを全量公開しています。',
    // 見出しの下に並べる短い一覧（検索や AI が「何が分かるか・どう試算するか」を拾えるように、本文の文字で書く）
    points: [
      { h: 'シミュレーションで分かること', items: ['必要な人数：ロボットを何台入れれば、スタッフを何人減らせるか', '待ち時間：ピーク時の入店待ちと、料理が届くまでの時間', '止まる場所：通路や席の配置で、ロボットが詰まる箇所', '費用：ロボットの月額と、浮く人件費の比較（補助金を含む）'] },
      { h: '試算の方法', items: ['時間・順番・台数：離散イベントシミュレーション（SimPy）', '空間と動き：3Dシミュレーション（Unity）', '前提の数字：公開事例と、店で数える観察シート', 'ブラウザで試せる試算：飲食店・病院・商業施設・ホテル・オフィス・屋外の6現場'] },
    ],
    sim: { src: '/sim/serving-robot.html', title: '配膳ロボット、何台入れたら店は変わる？',
      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。標準ファミレスは、店の形（平面図）から選ぶこともできます。レジの数（1つ・2つ）も選べます。ロボットの費用と浮く人件費も比べられます（補助金の情報つき）。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です（試作 v2.8・32,850条件）。',
      obs: { src: '/sim/serving-robot-observation.html', title: '試算の仮の数字を、店で数える観察シート', label: '観察シートを開く ↗',
        note: '配膳ロボットが通路で何回止まるかは、公開データが見つかりませんでした。店で片手で押して数えるための記録シートの例です。押した記録はこのブラウザの中にだけ保存され、どこにも送られません。',
        points: ['ロボットの出発・卓に着いた・止まった・戻ったを押すと、1往復の時間と止まった割合が出る', '止まった場所と原因を選べる。選択肢は記録する人の立場（客席から・店のスタッフ・導入や保守の担当）で切り替わる', '地図やルートを変える前と後の観察を並べて比べられる'] },
      tabs: [
        { id: 'serving', img: '/site/sim/serving.jpg?v=2', task: '配膳', place: '飲食店', src: '/sim/serving-robot.html', title: '', note: '' },
        { id: 'hospital', img: '/site/sim/hospital.jpg?v=1', task: '搬送', place: '病院', src: '/sim/service-robot-sites.html#hospital', title: '検体と薬剤の搬送、ロボットに任せると何が変わる？',
          note: '慈恵医大柏病院の公開事例の形（3つのルート・運行の時間帯・同じ階）をもとに、病院の広さを選び、人が運ぶ時間と、荷物が届くまでの時間を比べます。ロボットが途中で止まる割合など、公開されていない値は仮置きで、何段階か動かせます（試作 v1・3,276条件・各30回の平均）。' },
        { id: 'cleaning', img: '/site/sim/cleaning.jpg?v=2', task: '清掃', place: '商業施設・ビル', src: '/sim/service-robot-sites.html#cleaning', title: '夜間の床清掃、人の作業はどれだけ減る？',
          note: '施設の広さ・ロボットが拭ける広い床の割合・台数・引っかかる回数を切り替えると、人の作業時間と、ロボットに呼ばれる回数が変わります。施設全体で5%減と対象エリアで60%減という公開事例の差を、床の割合で確かめられます（試作 v1・5,840条件・各30回の平均）。' },
        { id: 'hotel', img: '/site/sim/hotel.jpg?v=2', task: '配送', place: 'ホテル', src: '/sim/service-robot-sites.html#hotel', title: 'アメニティの客室配送、フロントは楽になる？',
          note: 'ホテルの広さ（120室・200室・400室）を選び、客室からの依頼をロボットで届けると、フロントを離れる時間・届くまでの時間・チェックインの待ちがどう変わるかを比べます（試作 v1・2,379条件・各30回の平均）。' },
        { id: 'security', img: '/site/sim/security.jpg?v=1', task: '巡回', place: 'オフィスビル', src: '/sim/service-robot-sites.html#security', title: '夜間の警備、巡回をロボットに任せると？',
          note: 'ビルの広さを選び、ロボットが巡回して異常を知らせ、警備員が駆けつける形で、異常に人が着くまでの時間と、防災センターが空く時間を比べます。異常でないものを知らせる空振りの回数は仮置きです（試作 v1・1,674条件・各30回の平均）。' },
        { id: 'outdoor', img: '/site/sim/outdoor.jpg?v=2', task: '配送', place: '屋外（歩道）', src: '/sim/service-robot-sites.html#outdoor', title: '歩道の配送ロボット、何件から人が足りなくなる？',
          note: '店から家へ届けるロボットと遠隔のオペレーターで、配達エリアの広さ・注文の数・台数・1人が見られる台数を切り替えます。止まってオペレーターを呼ぶ回数は仮置きです（試作 v1・3,960条件・各30回の平均）。' },
      ] } },
};
```

### コード：src/pages/record/[slug].astro（記事の上と SNS の画像）

記事のページでも同じ関数を通し、記事の上の画像と SNS 用の画像（og:image）を新しいサムネイルにしています。

```astro
---
import Base from '../../layouts/Base.astro';
import { ymd } from '../../lib/format';
import { mediaHtml } from '../../lib/media';
import { fixBody, textOf, abs, AUTHOR, PUBLISHER, SITE, SITE_NAME } from '../../lib/seo';
import { SERIES_PAGES, allCards, withDeepDiveThumb } from '../../lib/records';
import RecordCard from '../../components/RecordCard.astro';
import { getCollection, render } from 'astro:content';
import { existsSync, readFileSync } from 'node:fs';
import aiConsult from '../../data/aiConsult.json';
import { linkifyHtml, emphasizeHtml, linkLogsHtml } from '../../lib/links.mjs';
import { roomyTables } from '../../lib/tables.mjs';
import emphasis from '../../data/emphasis.json';

// 記事ページは2種類から作る
// - Studio から移した記事：CMS の本文 HTML（src/data/records/。tools/import_all.py で作る）
// - これから Claude が書く記事：Markdown（src/content/records/）
export async function getStaticPaths() {
  const studio = Object.values(import.meta.glob('../../data/records/*.json', { eager: true, import: 'default' })) as any[];
  const md = await getCollection('records');
  return [
    ...studio.map((r) => ({ params: { slug: r.slug }, props: { kind: 'studio', rec: r } })),
    ...md.map((e) => ({ params: { slug: e.data.slug }, props: { kind: 'md', entry: e } })),
  ];
}
const { kind, entry, rec } = Astro.props;
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
const tocOf = (items) => items.length ? `<nav class="toc"><ul>${items.map((t) => `<li><a href="#${t.id}">${esc(t.text)}</a></li>`).join('')}</ul></nav>` : '';

let r, body = '', Content = null, toc = '';
if (kind === 'studio') {
  r = rec;
  body = fixBody(mediaHtml(rec.body.replace('<!--TOC-->', tocOf(rec.toc))), rec.title);
} else {
  const d = entry.data;
  r = { title: d.title, description: d.meta_description || '', slug: d.slug, day: `LOG${d.day}`, series: d.series,
        published: d.date.toISOString(), cover: d.cover ?? null, thumb: d.cover ? null : `/thumbs/LOG${d.day}.html`, tags: d.tags || [] };
  const rendered = await render(entry);
  Content = rendered.Content;
  toc = tocOf(rendered.headings.filter((h) => h.depth === 2).map((h) => ({ id: h.slug, text: h.text })));
  // 目次は最初の見出しの直前に置く（冒頭の書き出しを先に読んでもらうため。article_rules.md「冒頭の書き出し」）
  const html = entry.rendered?.html;
  if (html) {
    const at = toc ? html.search(/<h2[\s>]/) : -1;
    body = !toc ? html : at >= 0 ? html.slice(0, at) + toc + html.slice(at) : toc + html;
  }
}
// 本文の URL・サイトの住所をリンクにする（中の住所は同じタブ、外のサイトは新しいタブ。src/lib/links.mjs）
// 記事のいちばん大事な一文を太字にする（src/data/emphasis.json）
// 本文の記事番号（LOG334 など）を、その記事へのリンクにする（同じ記事へは最初の1か所だけ・自分へはつながない）。
// 古い呼び方の DAY200 は LOG200 に直す。LOG343（DAY から LOG への呼び方の変更を書いた記事）は DAY を例として書いているので直さない
const LOGS = Object.fromEntries((await allCards()).map((c) => [c.day, c.slug]));
const isEnglish = /-en$/.test(r.slug);
body = linkifyHtml(linkLogsHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []), LOGS, r.slug, isEnglish, r.slug !== 'studio-shutdown-cloudflare-redirect', r.slug === 'day39'));
// 表を窮屈にしない（本文より広い表は横スクロール・短いセルは折り返さない・長いセルは最小の幅を持たせる。src/lib/tables.mjs）
body = roomyTables(body);

// 検索エンジン向けの情報
// 考察記事は、問いを文字で組んだサムネイルを記事の上と SNS の画像にも使う（Gemini の絵より優先）
r = withDeepDiveThumb(r);
// SNS・検索結果に出す画像：表紙の画像 → サムネイルを書き出した画像（public/og/。tools/render_og.py）→ サイト共通の画像
const ogFromThumb = r.thumb ? r.thumb.replace(/^\/thumbs\//, '/og/').replace(/\.html$/, '.jpg') : null;
const ogImage = r.cover || (ogFromThumb && existsSync(`public${ogFromThumb}`) ? ogFromThumb : undefined);
const isEn = rec?.lang === 'EN' || /-en$/.test(r.slug);
const description = r.description || textOf(kind === 'studio' ? rec.body : entry.body, 120);
const allSlugs = new Set([
  ...Object.values(import.meta.glob('../../data/records/*.json', { eager: true, import: 'default' })).map((x: any) => x.slug),
  ...(await getCollection('records')).map((e) => e.data.slug),
]);
const pair = isEn ? r.slug.replace(/-en$/, '') : `${r.slug}-en`;
const alternates = allSlugs.has(pair) ? [{ lang: isEn ? 'ja' : 'en', path: `/record/${pair}` }] : [];
// 英語の考察記事（Consideration）は、考察記事のシリーズページ（series5）につなぐ
const seriesKey = r.series === 'Consideration' ? '考察記事' : r.series;
const seriesPage = Object.entries(SERIES_PAGES).find(([, c]) => c.series === seriesKey);
const seriesName = seriesPage ? seriesPage[1].title.split(' | ').pop() : r.series;

// 同じシリーズの記事（一覧と同じく LOG 番号の大きい順）。前後の記事と、この記事の近くの6本
const sameSeries = (await allCards()).filter((c) => c.series === r.series);
const pos = sameSeries.findIndex((c) => c.slug === r.slug);
const newer = pos > 0 ? sameSeries[pos - 1] : null;
const older = pos >= 0 && pos < sameSeries.length - 1 ? sameSeries[pos + 1] : null;
const others = sameSeries.filter((c) => c.slug !== r.slug);
const start = Math.max(0, Math.min((pos < 0 ? 0 : pos) - 3, others.length - 6));
const nearby = others.slice(start, start + 6);
// AI活用の記事（研究を回すために作った仕組みの記録）の末尾に、相談の案内を出す。
// Markdown の記事は先頭の項目 ai_consult: true、Studio から移した記事は src/data/aiConsult.json の slug で決める
const showConsult = kind === 'md' ? entry.data.ai_consult === true : (aiConsult as string[]).includes(r.slug);
const url = `${SITE}/record/${r.slug}`;
// 長いコード・ログ・データを外に出した記事（LOG364 から。tools/split_files.py）は、
// 全文（Markdown。Studio から移した記事は HTML）を head に、ファイルの一覧を構造化データ（hasPart）に出して、AI がたどれるようにする
const filesIndexPath = `public/files/${r.day}/index.json`;
const filesIndex = existsSync(filesIndexPath) ? JSON.parse(readFileSync(filesIndexPath, 'utf-8')) : null;
const codeLang = { csharp: 'C#', json: 'JSON', yaml: 'YAML', markdown: 'Markdown', python: 'Python', text: 'Text' };
const jsonLd = [
  {
    '@context': 'https://schema.org', '@type': 'BlogPosting',
    headline: r.title, description, url, mainEntityOfPage: url,
    inLanguage: isEn ? 'en' : 'ja',
    datePublished: r.published, dateModified: rec?.updated || r.published,
    image: ogImage ? [abs(ogImage)] : undefined,
    articleSection: r.series, keywords: r.tags?.length ? r.tags.join(', ') : undefined,
    author: AUTHOR, publisher: PUBLISHER,
    isPartOf: { '@type': 'Blog', name: SITE_NAME, url: SITE + '/' },
    hasPart: filesIndex ? [
      { '@type': 'DigitalDocument', name: `${r.day} 記事の全文（${filesIndex.article_full_type === 'text/html' ? 'HTML' : 'Markdown'}）`, encodingFormat: filesIndex.article_full_type || 'text/markdown', url: abs(filesIndex.article_full || filesIndex.article_full_markdown) },
      ...filesIndex.files.map((f) => f.lang === 'csharp' || f.lang === 'yaml' || /\.(cs|prefab|asset|mat)\.txt$/.test(f.file)
        ? { '@type': 'SoftwareSourceCode', name: f.subheading || f.heading || f.file, programmingLanguage: codeLang[f.lang] || f.lang, url: abs(f.url) }
        : { '@type': 'Dataset', name: [f.heading, f.subheading].filter(Boolean).join(' / ') || f.file, url: abs(f.url) }),
    ] : undefined,
  },
  {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: SITE_NAME, item: SITE + '/' },
      ...(seriesPage ? [{ '@type': 'ListItem', position: 2, name: seriesPage[1].title, item: `${SITE}/${seriesPage[0]}` }] : []),
      { '@type': 'ListItem', position: seriesPage ? 3 : 2, name: r.title, item: url },
    ],
  },
];
---
<Base title={`${r.title} | ${SITE_NAME}`} description={description} ogImage={ogImage} path={`/record/${r.slug}`}
  lang={isEn ? 'en' : 'ja'} ogType="article" published={r.published} modified={rec?.updated || r.published}
  alternates={alternates} jsonLd={jsonLd}>
  {filesIndex && <link slot="head" rel="alternate" type={filesIndex.article_full_type || 'text/markdown'} title="記事の全文（コードとログを含む）" href={filesIndex.article_full || filesIndex.article_full_markdown} />}
  <article class="rec">
    <nav class="crumbs" aria-label="現在地">
      <a href="/">{SITE_NAME}</a><span aria-hidden="true">›</span>{seriesPage ? <a href={`/${seriesPage[0]}`}>{seriesName}</a> : <span>{seriesName}</span>}
    </nav>
    {r.thumb ? <div class="hero"><iframe src={r.thumb} sandbox="allow-scripts" title={r.title} scrolling="no" tabindex="-1"></iframe></div> : r.cover && <img class="hero-img" src={r.cover} alt={r.title} width="1920" height="1080" />}
    <div class="meta"><span class="day">{r.day}</span>{seriesPage ? <a class="tag" href={`/${seriesPage[0]}`}>{r.series}</a> : <span class="tag">{r.series}</span>}<span class="date">{ymd(r.published)}</span></div>
    <h1 class="title">{r.title}</h1>
    {kind === 'studio'
      ? <div class="richText" set:html={body} />
      : body ? <div class="richText" set:html={body} /> : <div class="richText"><Fragment set:html={toc} /><Content /></div>}
    {showConsult && (
      <aside class="consult" aria-label="ご相談について">
        <p>この記事の仕組みは、フィジカルAIの研究をひとりで回すために作ったものです。同じような仕組みを仕事に取り入れたい方は、メッセージからご相談ください。</p>
        <a class="btn-dark" href="/message?topic=ai">メッセージで相談する<i class="ms">arrow_forward</i></a>
      </aside>
    )}
    {(newer || older) && (
      <nav class="pager" aria-label="同じシリーズの前後の記事">
        {older ? <a class="pg pg-prev" href={`/record/${older.slug}`}><span class="pg-k">← 前の記事　{older.day}</span><span class="pg-t">{older.title}</span></a> : <span />}
        {newer ? <a class="pg pg-next" href={`/record/${newer.slug}`}><span class="pg-k">次の記事　{newer.day} →</span><span class="pg-t">{newer.title}</span></a> : <span />}
      </nav>
    )}
  </article>
  {nearby.length > 0 && (
    <section class="related">
      <div class="panel">
        <h2 class="sec-h">{seriesName}の記録 | Series</h2>
        <div class="grid3">{nearby.map((c) => <RecordCard item={c} />)}</div>
        {seriesPage && <div class="rel-more"><a class="btn-dark" href={`/${seriesPage[0]}`}>{seriesName}の記録をすべて見る（{sameSeries.length}本）<i class="ms">arrow_forward</i></a></div>}
      </div>
    </section>
  )}
  {filesIndex && (
    <script is:inline>
      // 長いコード・ログの枠：押したときだけ全文を読み込んで、先頭の数行の所に出す（LOG364 から）
      document.addEventListener('click', async (e) => {
        const b = e.target.closest && e.target.closest('.filebox .fb-open');
        if (!b) return;
        const box = b.closest('.filebox'), code = box.querySelector('.fb-pre code');
        if (box.dataset.full === '1') { box.classList.toggle('fb-closed'); b.textContent = box.classList.contains('fb-closed') ? '全文をここに表示' : 'たたむ'; return; }
        b.disabled = true; b.textContent = '読み込み中…';
        try {
          const res = await fetch(box.dataset.src);
          code.textContent = await res.text();
          box.dataset.full = '1'; box.classList.add('fb-full'); b.textContent = 'たたむ';
        } catch (err) { b.textContent = '読み込めませんでした（別のタブで開いてください）'; }
        b.disabled = false;
      });
    </script>
  )}
</Base>

<style is:global>
  .rec { width: 620px; max-width: calc(100% - 48px); margin: 0 auto; padding: 104px 0 64px; }
  .rec .hero { aspect-ratio: 16 / 9; position: relative; border-radius: 4px; overflow: hidden; background: #0d0d0d; }
  .rec .hero iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
  .rec .hero-img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; display: block; }
  .rec .crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0 0 16px; font: 400 13px/20px var(--sans); color: var(--ink-3); }
  .rec .crumbs a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }
  .rec .crumbs a:hover { color: var(--ink); }
  .rec a.tag:hover { background: #e4e4e4; color: var(--ink-2); }
  .pager { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 64px 0 0; padding-top: 32px; border-top: 1px solid #ddd; }
  .pg { display: flex; flex-direction: column; gap: 8px; padding: 16px; border-radius: 8px; background: #fff; box-shadow: 0 8px 16px rgba(0,0,0,.08); }
  .pg:hover .pg-t { text-decoration: underline; }
  .pg-next .pg-k { text-align: right; }
  .pg-k { font: 400 12px/18px var(--sans); color: var(--ink-3); }
  .pg-t { font: 600 14px/1.6 var(--sans); color: var(--ink); overflow-wrap: anywhere; }
  .rel-more { display: flex; justify-content: center; margin-top: 40px; }
  .rel-more .btn-dark { height: auto; min-height: 40px; padding: 8px 20px; white-space: normal; text-align: center; }
  @media (max-width: 599px) { .pager { grid-template-columns: 1fr; } .pg-next .pg-k { text-align: left; } }
  .consult { margin: 56px 0 0; padding: 24px; border-radius: 8px; background: #f0f0f0; }
  .consult p { margin: 0 0 16px; font: 400 16px/28px var(--sans); color: var(--ink); }
  .consult .btn-dark { height: auto; min-height: 40px; padding: 8px 20px; white-space: normal; }
  .rec .meta { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
  .rec .day { font: 400 28px/28px var(--sans); }
  .rec .title { margin: 12px 0 40px; font: 600 28px/42px var(--sans); overflow-wrap: anywhere; }

  .richText { color: var(--ink); font-family: var(--sans); }
  .richText p { font-size: 18px; line-height: 36px; margin: 8px 0 12px; overflow-wrap: anywhere; }
  .richText h2 { font-size: 24px; line-height: 36px; font-weight: 600; margin: 40px 0 16px; }
  .richText h3 { font-size: 20px; line-height: 30px; font-weight: 600; margin: 36px 0 16px; padding-left: 12px; border-left: 2px solid var(--ink); }
  .richText h4 { font-size: 18px; line-height: 30px; font-weight: 600; margin: 24px 0 8px; }
  .richText h5 { font-size: 14px; line-height: 1.8; font-weight: 400; color: #555; margin: 48px 0 0; }
  .richText hr { border: 0; border-top: 1px solid #ddd; margin: 10px 0; }
  .richText strong { font-weight: 600; }
  .richText a { color: inherit; text-decoration: underline; }
  .richText pre { background: #eee; border-radius: 4px; padding: 25px 35px; margin: 4px 0; overflow-x: auto;
    font: 400 16px/24px var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
  .richText pre code { font: inherit; }
  .richText :not(pre) > code { background: #eee; padding: 0 5px; margin: 2px; border: 1px solid rgba(0,0,0,.1); border-radius: 6px; font: 400 18px/36px var(--sans); overflow-wrap: anywhere; }
  .richText blockquote { margin: 10px 0; padding: 10px 15px; border-left: 3px solid rgba(0,0,0,.15); color: #333; }
  .richText blockquote p { font: 400 16px/28.8px 'Noto Sans JP', sans-serif; margin: 10px 0; }
  .richText figure { margin: 16px 0; }
  .richText figure img { width: 100%; height: auto; }
  .richText figcaption:empty { display: none; }
  .richText ul, .richText ol { padding-left: 1.5em; }
  .richText li { font-size: 18px; line-height: 36px; margin: 10px 0; }
  /* 表は本文の幅に押し込まず、はみ出す分は横にスクロールする（縦に1文字ずつ折り返して読めなくなっていた。LOG355） */
  .richText .table { margin: 10px 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .richText table { border-collapse: collapse; width: auto; min-width: 100%; font-size: 14px; }
  /* 表は見出しの行を少し濃く、線をはっきり、本文の行は白に（ページの地色と見分けられるように） */
  .richText th, .richText td { border: 1px solid #d2d2d2; vertical-align: top; text-align: left; }
  .richText th { background: #e9e9e9; padding: 24px; font: 600 16px/24px var(--sans); }
  .richText td { background: #fff; padding: 16px; font: 400 18px/27px var(--sans); }
  .richText th p, .richText td p { margin: 0; font: inherit; }
  .richText .embed { margin: 16px 0; }
  .richText .embed iframe { display: block; width: 100%; height: 150px; border: 0; }
  .richText .video { margin: 16px 0; }
  .richText .video video { display: block; width: 100%; height: auto; }
  .richText .yt { aspect-ratio: 16 / 9; margin: 16px 0; }
  /* 長いコード・ログの枠（LOG364 から。tools/split_files.py） */
  .richText .filebox { margin: 8px 0 16px; border: 1px solid #d2d2d2; border-radius: 4px; background: #fff; }
  .richText .fb-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; padding: 12px 16px; border-bottom: 1px solid #e4e4e4; }
  .richText .fb-name { font: 600 14px/20px var(--mono); overflow-wrap: anywhere; text-decoration: none; border-bottom: 1px solid currentColor; }
  .richText .fb-meta { font: 400 13px/20px var(--sans); color: #666; }
  .richText .filebox .fb-pre { margin: 0; border-radius: 0; padding: 12px 16px; font-size: 13px; line-height: 20px; max-height: 180px; overflow: hidden; position: relative; }
  .richText .filebox.fb-full .fb-pre { max-height: 70vh; overflow: auto; }
  .richText .filebox.fb-full.fb-closed .fb-pre { max-height: 180px; overflow: hidden; }
  .richText .fb-act { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 10px 16px; border-top: 1px solid #e4e4e4; font: 400 14px/20px var(--sans); }
  .richText .fb-open { font: 600 14px/20px var(--sans); padding: 6px 12px; border: 1px solid var(--ink); border-radius: 4px; background: var(--ink); color: #fff; cursor: pointer; }
  .richText .fb-open:disabled { opacity: .6; cursor: default; }
  .richText .fb-all { padding: 8px 16px; background: #f6f6f6; }
  .richText .fb-all p { font-size: 16px; line-height: 28px; margin: 8px 0; }
  .richText .fb-all li { font-size: 16px; line-height: 28px; margin: 4px 0; }
  .richText .fb-all .fb-lh { font: 600 16px/28px var(--sans); margin: 16px 0 4px; }
  .richText .fb-list .fb-li { font-size: 14px; line-height: 22px; margin: 2px 0; overflow-wrap: anywhere; }
  .richText .yt iframe { width: 100%; height: 100%; border: 0; }

  .toc { background: #f0f0f0; border-radius: 4px; padding: 20px 20px 8px 8px; margin: 0 0 24px; }
  .toc ul { list-style: none; margin: 0; padding: 0; }
  .toc li { position: relative; padding-left: 32px; margin: 0 0 16px; font: 400 16px/32px var(--sans); color: var(--ink-2); }
  .toc a { text-decoration: none; color: inherit; }

  /* 本文より広いもの（表・画像）が画面の幅を押し広げないようにする */
  .richText { max-width: 100%; min-width: 0; }
  .richText a, .richText code, .richText li, .richText td, .richText th, .richText blockquote, .richText h2, .richText h3 { overflow-wrap: anywhere; word-break: break-word; }
  /* 表の中は、英数字の語（ファイル名・数値）を途中で折らない。見出しの行は日本語も途中で折らない */
  .richText td, .richText td code, .richText td a, .richText th, .richText th code { overflow-wrap: normal; word-break: normal; }
  .richText .nw, .richText .nw * { white-space: nowrap; }
  .richText .cw { min-width: 9em; }
  .richText pre code { white-space: pre-wrap; }
  .richText img { max-width: 100%; height: auto; }
  @media (max-width: 767px) {
    .rec { max-width: calc(100% - 48px); padding-top: 72px; }
    .rec .title { font-size: 24px; line-height: 36px; }
  }
</style>
```

### コード：tools/add_article.py（記事を入れるときにサムネイルを作る）

考察記事を入れるときに、記事の頭の `question:` と `key:` を読んでサムネイルを書き出すようにした、記事を入れる道具の全文です。

```python
"""記事の作業フォルダ（iCloud）から、記事1本をサイトに入れる。

使い方:
  python3 tools/add_article.py <作業フォルダ> <LOG番号> <シリーズ名> [--no-split]

やること:
- LOG{N}_article.md      → src/content/records/LOG{N}.md（先頭の項目に series を足す）
- LOG{N}_thumbnail.html  → public/thumbs/LOG{N}.html（考察記事は使わない。記事の頭の question・key から tools/deep_dive_thumb.py で書き出す）
- 本文の【図解】の HTML  → public/embeds/（図解の高さを親ページに知らせる仕組みを足す）
- 本文の【画像】の画像    → あるかを確かめるだけ。画像は GitHub に入れず、Mac で tools/upload_media.py を使って R2 に入れる
- 長いコード・ログ・データ → tools/split_files.py で public/files/LOG{N}/ に分け、押したときだけ読み込む形にする
                            （2026-10-23 から全記事。--no-split を付けると分けずに全文を記事に入れる。元の載せ方に戻すときに使う）

このあと、Claude のクラウドの作業環境で tools/render_og.py LOG{N} を動かし、
サムネイルを SNS・検索結果用の画像（public/og/LOG{N}.jpg）に書き出す（Mac には Playwright がないため）
"""
import re, shutil, sys
from pathlib import Path

args = [a for a in sys.argv[1:] if not a.startswith("--")]
work, day, series = Path(args[0]), args[1], args[2]
no_split = "--no-split" in sys.argv
site = Path(__file__).resolve().parent.parent
md_src = work / f"LOG{day}_article.md"
text = md_src.read_text(encoding="utf-8")

# 先頭の項目（---で囲まれた部分）に series を入れる
fm_end = text.index("\n---", 3)
fm, body = text[:fm_end], text[fm_end:]
if re.search(r"^series:", fm, flags=re.M):
    fm = re.sub(r"^series:.*$", f'series: "{series}"', fm, flags=re.M)
else:
    fm += f'\nseries: "{series}"'
out = site / "src/content/records" / f"LOG{day}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(fm + body, encoding="utf-8")

reporter = re.search(r'HEIGHT_REPORTER = """(.*?)"""',
                     (site / "tools/import_all.py").read_text(encoding="utf-8"), re.S).group(1)

thumb = work / f"LOG{day}_thumbnail.html"
deep_dive = series == "考察記事"
if deep_dive:
    # 考察記事のサムネイルは、問いを文字で組んだ形（2026-10-26 から）。記事の頭の項目の question と key から書き出す
    # question: 問い（縦書きで改行したい所に「/」、1行9字まで）／ key: 強く見せる一語（問いの1行の中にある語）
    q = re.search(r'^question:\s*"?(.*?)"?\s*$', fm, flags=re.M)
    k = re.search(r'^key:\s*"?(.*?)"?\s*$', fm, flags=re.M)
    if not (q and k):
        raise SystemExit("考察記事は、記事の頭の項目に question（問い）と key（強く見せる一語）を書いてください（tools/deep_dive_thumb.py）")
    import subprocess
    subprocess.run([sys.executable, str(site / "tools/deep_dive_thumb.py"), "--add", day, q.group(1), k.group(1)], check=True)
elif thumb.exists():
    (site / "public/thumbs").mkdir(parents=True, exist_ok=True)
    shutil.copy2(thumb, site / "public/thumbs" / f"LOG{day}.html")

for name in re.findall(r"^【図解】\s*(\S+\.html)\s*$", body, flags=re.M):
    doc = (work / name).read_text(encoding="utf-8")
    doc = doc.replace("</body>", reporter + "</body>", 1) if "</body>" in doc else doc + reporter
    (site / "public/embeds" / name).write_text(doc, encoding="utf-8")

missing = []
for name in re.findall(r"^【画像】\s*(\S+)(?:\s+.*)?$", body, flags=re.M):  # 「【画像】ファイル名 説明」の説明は alt になる
    src = work / f"LOG{day}_images" / name
    if not src.exists():
        missing.append(name)

# 長いコード・ログ・データを外に出す（2026-10-23 から、これから入れる記事はすべて）
files_dir = site / "public/files" / f"LOG{day}"
if no_split:
    if files_dir.exists():
        shutil.rmtree(files_dir)
else:
    import subprocess
    subprocess.run([sys.executable, str(site / "tools/split_files.py"), day], check=True)

print(f"次に: python3 tools/render_og.py LOG{day}（SNS・検索結果用の画像を書き出す）")
print(f"LOG{day}: 記事 {out.name}, シリーズ {series}" + (f", 見つからない画像 {missing}" if missing else ""))
if missing:
    sys.exit(1)
```

### コード：tools/render_og.py（SNS 用の画像の書き出し）

書体が届いてから文字を出すようにしたので、SNS 用の画像を撮る道具も、文字が出るまで待つようにしました。

```python
"""記事のサムネイル（public/thumbs/*.html）を、SNS・検索結果に出す画像（public/og/*.jpg）に書き出す。

XやLinkedInで記事のリンクを貼ったときに出る画像は、HTMLでは出せないため、画像にしておく。
記事ページは、この画像があれば og:image に使い、なければサイト共通の画像を使う。

使い方（Chromium と Playwright のある Claude のクラウドの作業環境で動かす。Mac には Playwright がない）:
  python3 tools/render_og.py            # まだ画像がないサムネイルだけ書き出す
  python3 tools/render_og.py LOG344     # 指定したものだけ書き出し直す（ファイル名から .html を除いた名前）
  python3 tools/render_og.py --all      # 全部書き出し直す

大きさは 1200×675（16:9）。Google が大きな画像として扱う幅 1200 以上にそろえる。
"""
import os
import sys
from pathlib import Path

from playwright.sync_api import sync_playwright

W, H = 1200, 675
site = Path(__file__).resolve().parent.parent
thumbs = site / "public/thumbs"
out_dir = site / "public/og"
out_dir.mkdir(parents=True, exist_ok=True)

args = sys.argv[1:]
names = sorted(p.stem for p in thumbs.glob("*.html"))
if "--all" in args:
    targets = names
elif args:
    targets = [a.removesuffix(".html") for a in args]
else:
    targets = [n for n in names if not (out_dir / f"{n}.jpg").exists()]

exe = os.environ.get("CHROMIUM_PATH") or next(
    (str(p) for p in Path("/opt/pw-browsers").glob("chromium-*/chrome-linux/chrome")), None)

done = []
with sync_playwright() as pw:
    browser = pw.chromium.launch(executable_path=exe) if exe else pw.chromium.launch()
    page = browser.new_page(viewport={"width": W, "height": H}, device_scale_factor=1)
    for name in targets:
        src = thumbs / f"{name}.html"
        if not src.exists():
            print(f"見つからない: {src}")
            continue
        page.goto(src.as_uri(), wait_until="networkidle")
        page.evaluate("document.fonts.ready")
        # 考察記事のサムネイル（tools/deep_dive_thumb.py）は、書体が届いてから文字を出す。出るまで待つ
        try:
            page.wait_for_function("!document.documentElement.classList.contains('wait')", timeout=8000)
        except Exception:
            pass
        # 動くサムネイルは、動きが落ち着いたところで撮る
        page.wait_for_timeout(2500)
        page.screenshot(path=str(out_dir / f"{name}.jpg"), type="jpeg", quality=85,
                        clip={"x": 0, "y": 0, "width": W, "height": H})
        done.append(name)
    browser.close()

print(f"書き出し {len(done)} 件 → public/og/")
```

### コード：src/pages/index.astro（トップのシリーズのカード）

トップのシリーズのカードの並び（日本語を先に）と、7枚の日本語の説明、考察記事のカードの画像の差し替えが入ったトップのページの全文です。

```astro
---
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import { ymd } from '../lib/format';
import { media } from '../lib/media';
// SNS アイコン（Font Awesome Free 6.7.2・CC BY 4.0。ライセンス表記は各 SVG の中に残している）
import icGithub from '../icons/github.svg?raw';
import icLinkedin from '../icons/linkedin.svg?raw';
import icX from '../icons/square-x-twitter.svg?raw';
import icYoutube from '../icons/youtube.svg?raw';
import { allCards } from '../lib/records';
import MangaCard from '../components/MangaCard.astro';
import LoadMore from '../components/LoadMore.astro';
import GuideCards from '../components/GuideCards.astro';
import ThinkingBanner from '../components/ThinkingBanner.astro';
import OneYearBanner from '../components/OneYearBanner.astro';
import { allManga } from '../lib/records';
// Studio のトップと同じく、4コマは新しい4話
const mangaCards = allManga.slice(0, 4);

const series = [
  { href: '/series1', img: 's-1920x1318_v-frms_4cd7792b-68a1-4239-ad08-0785c4e538c5.webp', en: 'Drug Delivery Robot', ja: '薬剤搬送ロボットハコブくん',
    desc: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり（HRI）を設計する記録。画面の設計、Unity での自律移動、日々の開発の記録を、4コママンガと一緒に残しています。' },
  { href: '/series6', img: 's-1672x941_76620d86-fa7b-491f-b554-99c2f8981d8d.webp', en: 'Physical AI Lab', ja: 'フィジカルAI研究室',
    desc: 'ロボットアームが動きを覚える環境を、自宅にひとりで作る記録。お手本の動きを集めてロボット用のAIに学習させ、実機で確かめます。誰でも同じ手順で始められる形で残しています。' },
  { href: '/series7', img: 's-1800x1200_v-frms_82af05c4-20df-4ea1-b88d-355ed8fabd63.webp', en: 'Image recognition and Object detection', ja: '画像認識と物体検出',
    desc: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直す記録。画素・色・しきい値・輪郭といった手法を、机の上のアームを撮った写真で1つずつ測って確かめます。' },
  { href: '/series8', img: '/site/series8-service-robot-simulation.png?v=3', en: 'Service Robot Simulation', ja: 'サービスロボット導入シミュレーション',
    desc: '配膳・清掃・搬送などのロボットを店や施設に入れる前に、待ち時間・人数・止まる場所・費用を試算する記録。時間と台数は SimPy、空間と動きは Unity で扱い、前提・結果・コードをすべて公開しています。' },
  { href: '/series5', img: '/site/series5-deep-dive.jpg', // 考察記事のサムネイルと同じ形（3-B）。元の HTML は tools/series5_top_card.html（2026-10-26）
    en: 'Deep Dive', ja: '考察記事',
    desc: '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事。外れた議論も含めて、対話をそのまま残しています。' },
  { href: '/series2', img: 's-1920x1318_v-frms_0d775000-730b-4ac2-90b5-d814a9f53fc6.webp', en: 'R2R2R', ja: 'デジタルツイン学習-R2R2R（Real to Render to Real）',
    desc: 'iPhone で物を3Dスキャンし、Unity で動きを試してから、机の上のロボットアームで実際に動かす記録。ロボット工学を学んでいないデザイナーでも、アームの動きを設計できるかを試しています。' },
  { href: '/series4', img: '/site/series4-behind-the-solo-lab-door.png', en: 'Behind the Solo Lab', ja: 'ひとり研究所の裏側', desc: 'フィジカルAIの研究をひとりで続けるために、AIと一緒に作ってきた仕組みの記録。サイト、動画、記録の整理など、研究の外側の作業をどう回しているかを残しています。' },
];
const records = await allCards();
import { SITE, SITE_NAME, AUTHOR, PUBLISHER } from '../lib/seo';
const jsonLd = [
  { '@context': 'https://schema.org', '@type': 'WebSite', name: SITE_NAME, url: SITE + '/', inLanguage: 'ja', publisher: PUBLISHER },
  { '@context': 'https://schema.org', ...PUBLISHER, founder: AUTHOR,
    sameAs: ['https://www.youtube.com/@PixelstoMotors', 'https://github.com/PixelstoMotors', 'https://x.com/adaptdesignjp'] },
  { '@context': 'https://schema.org', ...AUTHOR, sameAs: ['https://www.linkedin.com/in/eiichi-shimazu/', 'https://x.com/adaptdesignjp'], description: '29年のデザイナー経験を持つデザイナー／ディレクター。Pixels to Motors で人とロボットの関わり（HRI）とフィジカルAIを研究・発信している。' },
];
---
<Base title="Pixels to Motors ｜ 人とロボットの関わりを、シミュレーションでデザインする"
  description="29年のデザイナー経験を、いまロボティクスに応用しています。机の上のロボットアームでお手本のデータを集め、AIに学習させ、動かして確かめる。その環境を自宅で組み、誰でも同じ手順で始められる形で公開しています。"
  ogImage="/site/ffb300c2-1071-4714-978c-87d386d5dfd3.png" path="/" jsonLd={jsonLd}>

  <section class="hero">
    <div class="hero-text">
      <h1>Pixels to Motors | 人とロボットの関わりをシミュレーションでデザインする</h1>
      <p>29年のデザイナー経験を、いまロボティクスに応用しています。机の上のロボットアームでお手本のデータを集め、AIに学習させ、動かして確かめる。その環境を自宅で組み、誰でも同じ手順で始められる形で公開しています。基準は物理精度ではなく、人がロボットをどう感じ、どう関わるかという体験の成立。過程の失敗も含めて公開しています。</p>
    </div>
    <video class="hero-video" src={media("/media/hakobu_fv_voice_eleven.mp4") + "#t=0.01"} controls muted autoplay loop playsinline></video>
  </section>

  <!-- 解説動画：YouTube の新着を GitHub の videos.json から読む埋め込み（Studio で使っていたものをそのまま） -->
  <section class="sec"><div class="panel">
    <div class="embed-wide"><iframe src="/embeds/top-video.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="解説動画" data-embed style="height:450px"></iframe></div>
  </div></section>

  <!-- 1年の LP（/one-year）への入口。動画の次、導入前ガイドの前に置く（2026-10-25）
       MASTER 2026-10-25：デジタルの AI の土台を記録から示すページは、トップと SNS に出してよい（AI活用の相談の看板にはしない） -->
  <section class="sec"><div class="panel">
    <h2 class="sec-h">Pixels to Motors の1年 | Year One</h2>
    <OneYearBanner />
  </div></section>

  <!-- 導入前ガイド：LP 2つ（GPU を買うか借りるか・配膳ロボットの導入前シミュレーション）への入口 -->
  <section class="sec"><div class="panel">
    <h2 class="sec-h">導入前ガイド | Guide</h2>
    <GuideCards />
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">4コママンガ | Archive</h2>
    <div class="grid4">{mangaCards.map((m) => <MangaCard item={m} />)}</div>
    <div class="center"><a class="btn-more" href="/manga-archive">もっと見る<i class="ms">arrow_forward</i></a></div>
  </div></section>

  <!-- 考えのページ（/thinking）への入口。4コマとシリーズの間に置く
       （2026-10-24 のアクセスの記録：TOP は25%の深さまでで42%、下まで33%しか読まれない。一番下では届かないため上げた。最初の画面の直下は上すぎるとエイイチの判断） -->
  <section class="sec"><div class="panel">
    <h2 class="sec-h">いまの考え | Thinking</h2>
    <ThinkingBanner />
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">シリーズ | Series</h2>
    <div class="grid2">
      {series.map((s) => (
        <a class="scard" href={s.href}>
          <img src={s.img.startsWith('/') ? s.img : media(`/media/${s.img}`)} alt={`${s.ja}｜${s.en}`} loading="lazy" />
          <div class="sc-meta">
            <div class="sc-line"><span class="sc-ja">{s.ja}</span><span class="sc-en">{s.en}</span></div>
            {s.desc && <p class="sc-desc">{s.desc}</p>}
          </div>
        </a>
      ))}
    </div>
  </div></section>

  <section class="sec" id="record"><div class="panel">
    <h2 class="sec-h">記録 | Record</h2>
    <LoadMore limit={12} total={records.length}>{records.map((c) => <RecordCard item={c} />)}</LoadMore>
    <div class="embed-banner"><iframe src="/embeds/top-banner.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" title="お知らせ" data-embed style="height:240px"></iframe></div>
  </div></section>

  <section class="sec" id="about"><div class="panel">
    <h2 class="sec-h">事業所について | Sole Prop</h2>
    <div class="prop">
      <div class="prop-person">
        <video class="prop-photo" src={media("/media/s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4")} poster={media("/media/s-2048x2048_5af0bc9f-8d23-4bdb-85e4-ada51bfd7bbd.webp")} muted autoplay loop playsinline></video>
        <p class="prop-role">Robotics UX &amp; HRI Designer</p>
        <p class="prop-name">島津 栄一</p>
      </div>
      <div class="prop-body">
        <p class="prop-lead">画面（Pixels）のデザインから、動き（Motors）のデザインへ</p>
        <p class="prop-text">29年にわたるデザイナーとしての知見を土台に、人とロボットの関わり（HRI）と、ロボットが学習できる環境づくりに取り組むデザイン事務所です。Unityでのシミュレーション、実機のロボットアーム、借りたGPUでの学習・推論までを自分の手で回し、AIエージェント（Claude Code）を設計と実装の相棒にしています。基準は物理精度ではなく体験の成立。画面上の「Pixels（デジタル）」の設計を、現実の「Motors（モーター）」へ——その過程を公開しています。</p>
        <dl class="prop-facts">
          <div><dt>事業所名</dt><dd>ADAPT DESIGN</dd></div>
          <div><dt>設立</dt><dd>2021年10月</dd></div>
          <div class="wide"><dt>研究・実践分野</dt><dd>フィジカルAI（学習・推論） / HRI / ロボティクスUX設計 / デジタルツイン / 画像認識 / Unity / R2R2R</dd></div>
          <div><dt>SNS</dt><dd class="sns">
            <a href="https://github.com/PixelstoMotors/r2r2r-content" aria-label="GitHub" set:html={icGithub} />
            <a href="https://www.linkedin.com/in/eiichi-shimazu/" aria-label="LinkedIn" set:html={icLinkedin} />
            <a href="https://x.com/adaptdesignjp" aria-label="X" set:html={icX} />
            <a href="https://www.youtube.com/@PixelstoMotors" aria-label="YouTube" set:html={icYoutube} />
          </dd></div>
        </dl>
      </div>
    </div>
  </div></section>
</Base>

<style>
  .hero { max-width: 964px; margin: 0 auto; padding: 104px 0 40px; display: flex; gap: 40px; align-items: flex-start; }
  .hero-text { flex: 1 1 588px; min-width: 0; }
  .hero h1 { margin: 0 0 16px; font: 600 29px/40.6px var(--sans); }
  .hero p { margin: 0; font: 400 19px/1.6 'Noto Sans JP', sans-serif; color: #333; }
  .hero-video { flex: 0 0 336px; width: 336px; height: 320px; background: #111; border-radius: 4px; object-fit: cover; }
  .embed-wide { margin: 0 -16px; } /* 埋め込み側の左右 16px の余白ぶん外へ出し、カードの端をほかの節と揃える */
  .embed-wide iframe, .embed-banner iframe { display: block; width: 100%; border: 0; }
  .embed-banner { max-width: 964px; margin: 48px auto 0; }

  .grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }

  .grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
  .scard { display: block; background: #fff; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,.08); overflow: hidden; }
  .scard img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
  .sc-meta { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
  .sc-line { display: flex; align-items: baseline; gap: 8px 12px; flex-wrap: wrap; }
  /* 日本語を先に、大きく置く（シリーズのページの見出し「考察記事 | Deep Dive」とそろえる。2026-10-26） */
  .sc-ja { font: 600 24px/1.3 var(--sans); }
  .sc-en { font: 500 14px/16.8px var(--sans); }
  .sc-desc { margin: 0; font: 400 14px/1.7 var(--sans); color: #555; }

  .center { display: flex; justify-content: center; margin-top: 40px; }
  .btn-more { display: inline-flex; align-items: center; gap: 16px; padding: 8px 24px; border-radius: 4px; background: var(--dark); color: #fff; font: 500 20px/30px var(--sans); }

  .prop { display: flex; gap: 40px; }
  .prop-person { flex: 0 0 160px; text-align: center; }
  .prop-photo { width: 160px; height: 160px; border-radius: 50%; object-fit: cover; display: block; }
  .prop-role { margin: 12px 0 4px; font: 300 12px/1.3 var(--sans); color: #888; }
  .prop-name { margin: 0; font: 500 20px/20px var(--sans); }
  .prop-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
  .prop-lead { margin: 0; padding: 10px 8px; background: #f7f7f7; font: 600 18px/1.4 var(--sans); }
  .prop-text { margin: 0; font: 400 14px/1.5 var(--sans); color: #333; }
  .prop-facts { margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 16px 40px; }
  .prop-facts dt { font: 300 12px/15.6px var(--sans); color: #888; }
  .prop-facts dd { margin: 4px 0 0; font: 400 14px/1.5 var(--sans); }
  .sns { display: flex; gap: 12px; }
  .sns a { display: inline-flex; color: #242424; }
  .sns a :global(svg) { height: 18px; width: auto; display: block; }
  .sns a:hover { color: #666; }

  @media (max-width: 1023px) {
    .hero { padding: 88px 24px 32px; flex-direction: column; gap: 24px; }
    /* 縦並びでは flex-basis の 588px が高さとして効き、文章と動画の間が大きく空いていた */
    .hero-text { flex: none; }
    .hero-video { width: 100%; flex-basis: auto; }
    .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  @media (max-width: 599px) {
    .hero { padding: 72px 24px 24px; }
    .hero h1 { font-size: 24px; line-height: 1.5; }
    .hero p { font-size: 16px; line-height: 1.75; }
    .grid2 { grid-template-columns: 1fr; gap: 24px; }
    .grid4 { gap: 16px; }
    .prop { flex-direction: column; align-items: center; gap: 24px; }
  }
  .guide-wrap { max-width: 1280px; margin: 0 auto; }
</style>
```

### コード：tools/series5_top_card.html（トップの考察記事のカードの元）

トップの考察記事のカードの画像は、このページを 960×640・2倍で撮って `public/site/series5-deep-dive.jpg` に書き出したものです。

```html
<!doctype html>
<!-- TOP のシリーズのカード（考察記事）の絵の元。直したら、書体が出たあと 960×640・2倍で撮って public/site/series5-deep-dive.jpg に書き出す -->
<html lang="ja" class="wait">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>考察記事 TOP のカード</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@200;300;400;700;900&display=block" rel="stylesheet">
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { background: #f5f3ee; }
  .wait .wrap > * { visibility: hidden; }
  .wrap { width: 100%; aspect-ratio: 3 / 2; container-type: inline-size; position: relative; overflow: hidden; background: #f5f3ee; color: #22201c;
    font-family: "Noto Serif JP", "Hiragino Mincho ProN", "Yu Mincho", serif; }
  .q { position: absolute; right: 9cqw; top: 6cqw; writing-mode: vertical-rl; white-space: nowrap; letter-spacing: .04em; line-height: 1.55; }
  .s { font-size: 5cqw; font-weight: 300; }
  .b { font-size: 21cqw; font-weight: 900; line-height: 1.1; vertical-align: middle; }
  .m { font-size: 6.2cqw; font-weight: 700; line-height: 1.25; vertical-align: middle; }
</style>
</head>
<body>
<div class="wrap"><p class="q"><span class="s">ロボットに、</span><br><span class="b">何を</span><br><span class="m">任せればいいのか。</span></p></div>
<script>
  (function () {
    var done = false;
    function show() { if (done) return; done = true; var w = document.querySelector('.wrap'); w.style.display = 'none'; void w.offsetHeight; w.style.display = ''; document.documentElement.classList.remove('wait'); }
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { requestAnimationFrame(show); }); else show();
    setTimeout(show, 4000);
  })();
</script>
</body>
</html>
```

### コード：src/pages/preview/deep-dive-thumbs.astro（確認用のページ）

案を比べるのに使った確認用のページです。考察記事のページと同じ組みで、最初に見える12本だけサムネイルを差し替え、ボタンで案を切り替えます。検索には出さない設定です。

```astro
---
// 確認用：考察記事のサムネイルを文字だけで組んだとき、一覧でどう見えるか（2026-10-26）
// /series5 と同じ組み（見出し・考えのページのバナー・記録の一覧）で、最初に見える12本だけサムネイルを差し替える
// 案は上のボタンで切り替える（サムネイルは tools/deep_dive_thumb.py で書き出す）
// 検索に出さない（noindex・サイトマップから外す）。どこからもリンクしない。確かめ終わったら消す
import Base from '../../layouts/Base.astro';
import RecordCard from '../../components/RecordCard.astro';
import ThinkingBanner from '../../components/ThinkingBanner.astro';
import { seriesCards, SERIES_PAGES } from '../../lib/records';
import conf from '../../../tools/deep_dive_thumb_preview.json';
import { media } from '../../lib/media';

// 案3（文の強弱）の強弱の付け方だけを変えた4つ。案1・案2・最初の試作は #p1・#p2・#plain で開ける
const PLANS = [
  { id: 'p3', label: '案3 今の形', tab: true },
  { id: 'p3a', label: '3-A 差を控えめに', tab: true },
  { id: 'p3b', label: '3-B 3段階', tab: true },
  { id: 'p3c', label: '3-C 太さだけで差', tab: true },
  { id: 'p1', label: '案1 一語を大きく', tab: false },
  { id: 'p2', label: '案2 黒い面で分ける', tab: false },
  { id: 'plain', label: '最初の試作', tab: false },
];
const page = SERIES_PAGES.series5;
const ready = new Set(conf.items.map((i) => `LOG${i.log}`));
const base = (await seriesCards(page.series)).slice(0, page.limit);
const cardsFor = (plan: string) => base.map((c) => (ready.has(c.day) ? { ...c, thumb: `/preview/deep-dive/${plan}/${c.day}.html`, cover: null } : c));
const [enName, jaName] = page.title.split(' | ');
// TOP のシリーズのカード（考察記事）の絵の案。public/preview/deep-dive/top/{a,b,c}.html
const TOP = [
  { id: 'a', label: 'A ひとつの大きな問い', note: '考えのページと同じ問いを 3-B の形で組む', src: '/preview/deep-dive/top/a.html' },
  { id: 'b', label: 'B 問いの数', note: '記事ごとの一語を大小つけて敷き詰める。新しい記事の語から並ぶ', src: '/preview/deep-dive/top/b.html' },
  { id: 'c', label: 'C 最新の記事', note: '新しい4本のサムネイルを 2×2 で。記事が増えると入れ替わる', src: '/preview/deep-dive/top/c.html' },
  { id: 'now', label: '今の絵', note: 'Gemini の4コマ', img: media('/media/s-2528x1694_7a2b46a9-02c8-49d6-92c4-2e7994843726.webp') },
];
const DESC = '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事。外れた議論も含めて、対話をそのまま残しています。';
---
<Base title="確認用：考察記事のサムネイル | Pixels to Motors" path="/preview/deep-dive-thumbs" noindex>
  <section class="s-hero"><div class="panel">
    <h1 class="sec-h s-title">{jaName} | {enName}</h1>
    <p class="s-intro">{page.intro}</p>
    <div class="s-guide"><ThinkingBanner /></div>
  </div></section>
  <section class="sec"><div class="panel">
    <h2 class="sec-h">記録 | Record</h2>
    <div class="pv-tabs" role="tablist" aria-label="サムネイルの案">
      {PLANS.filter((p) => p.tab).map((p, i) => <button type="button" role="tab" class="pv-tab" data-plan={p.id} aria-selected={i === 0 ? 'true' : 'false'}>{p.label}</button>)}
    </div>
    {PLANS.map((p, i) => (
      <div class="grid3 pv-grid" data-plan={p.id} hidden={i !== 0}>{cardsFor(p.id).map((c) => <RecordCard item={c} />)}</div>
    ))}
  </div></section>
  <section class="sec" id="top"><div class="panel">
    <h2 class="sec-h">TOPのシリーズのカード（案）</h2>
    <p class="pv-note">TOP の「シリーズ | Series」に並ぶ考察記事のカードの絵です。カードの形と文字は TOP と同じにしています。</p>
    <div class="pv-top">
      {TOP.map((t) => (
        <figure class="pv-fig">
          <figcaption><b>{t.label}</b><span>{t.note}</span></figcaption>
          <div class="scard">
            {t.src ? <div class="sc-img"><iframe src={t.src} title={t.label} scrolling="no" tabindex="-1"></iframe></div> : <img src={t.img} alt="" />}
            <div class="sc-meta">
              <div class="sc-line"><span class="sc-ja">考察記事</span><span class="sc-en">Deep Dive</span></div>
              <p class="sc-desc">{DESC}</p>
            </div>
          </div>
        </figure>
      ))}
    </div>
  </div></section>
  <script>
    const tabs = [...document.querySelectorAll<HTMLButtonElement>('.pv-tab')];
    const grids = [...document.querySelectorAll<HTMLElement>('.pv-grid')];
    const show = (id: string) => {
      tabs.forEach((t) => t.setAttribute('aria-selected', String(t.dataset.plan === id)));
      grids.forEach((g) => { g.hidden = g.dataset.plan !== id; });
    };
    tabs.forEach((t) => t.addEventListener('click', () => { show(t.dataset.plan!); history.replaceState(null, '', `#${t.dataset.plan}`); }));
    const start = location.hash.slice(1);
    if (grids.some((g) => g.dataset.plan === start)) show(start);
  </script>
</Base>
<style>
  .s-hero { padding: 88px 80px 0; }
  .s-title { margin: 0; }
  .s-guide { margin: 24px 0 0; }
  .s-intro { margin: 16px 0 0; font: 400 16px/28px var(--sans); color: var(--ink-2); }
  .pv-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: -8px 0 32px; }
  .pv-tab { height: 40px; padding: 0 16px; border: 1px solid var(--ink, #242424); border-radius: 4px; background: #fff; color: var(--ink, #242424); font: 600 14px/1 var(--sans); cursor: pointer; }
  .pv-tab[aria-selected="true"] { background: var(--ink, #242424); color: #fff; }
  .pv-grid[hidden] { display: none; }
  .pv-note { margin: -8px 0 24px; font: 400 14px/24px var(--sans); color: var(--ink-2); }
  .pv-top { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 40px 32px; }
  @media (max-width: 767px) { .pv-top { grid-template-columns: minmax(0, 1fr); } }
  .pv-fig { margin: 0; }
  .pv-fig figcaption { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; margin: 0 0 12px; font: 400 13px/20px var(--sans); color: var(--ink-2); }
  .pv-fig figcaption b { font: 600 15px/20px var(--sans); color: var(--ink); }
  .scard { display: block; background: #fff; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,.08); overflow: hidden; }
  .scard img, .sc-img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; display: block; position: relative; }
  .sc-img iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
  .sc-meta { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
  .sc-line { display: flex; align-items: baseline; gap: 8px 12px; flex-wrap: wrap; }
  .sc-ja { font: 600 24px/1.3 var(--sans); }
  .sc-en { font: 500 14px/16.8px var(--sans); }
  .sc-desc { margin: 0; font: 400 13px/1.6 var(--sans); color: #555; }
  @media (max-width: 1023px) { .s-hero { padding: 72px 24px 0; } }
  @media (max-width: 599px) { .s-hero { padding: 64px 12px 0; } }
</style>
```

### コード：読み込み直しても同じ画面になるかを確かめたスクリプト

同じサムネイルを新しいブラウザで5回ずつ読み込み、画面の画像が何通りあるかを数えました。

```python
from pathlib import Path
import hashlib
from playwright.sync_api import sync_playwright
exe = next((str(p) for p in Path("/opt/pw-browsers").glob("chromium-*/chrome-linux/chrome")), None)
res={}
with sync_playwright() as pw:
    b = pw.chromium.launch(executable_path=exe) if exe else pw.chromium.launch()
    for l in [375,316,319,323,324]:
        hs=set()
        for r in range(5):
            ctx=b.new_context(viewport={'width':340,'height':191}, device_scale_factor=2)
            p=ctx.new_page()
            p.goto(f'file:///home/claude/p2m-site/public/preview/deep-dive/p3b/LOG{l}.html')
            p.wait_for_function("!document.documentElement.classList.contains('wait')", timeout=8000)
            p.wait_for_timeout(200)
            hs.add(hashlib.md5(p.screenshot()).hexdigest()); ctx.close()
        res[l]=len(hs)
    b.close()
print(res)
```

### 出力：読み込み直しの確かめ（サムネイルごとの、画面の種類の数）

5本とも、5回の読み込みで画面は1通りでした。

```text
{375: 1, 316: 1, 319: 1, 323: 1, 324: 1}
```

### コード：Gemini の絵と新しいサムネイルの重さを数えたスクリプト

表紙の画像は公開サイトから実際に落として大きさを数え、新しいサムネイルは HTML のファイルの大きさを数えました。

```python
import json,re,glob,subprocess,os
# Gemini の絵（表紙の画像）の大きさ：公開サイトから実際に落として数える
covers={}
for c in json.load(open('src/data/cards.json')):
    if c['series']=='考察記事' and c.get('cover'): covers[int(re.match(r'LOG(\d+)',c['day']).group(1))]=c['cover']
for f in glob.glob('src/content/records/*.md'):
    fm=open(f).read().split('---')[1]
    if re.search(r'^series:\s*"?考察記事',fm,re.M):
        m=re.search(r'^cover:\s*"?([^"\n]+)',fm,re.M); d=int(re.search(r'^day:\s*"?(\d+)',fm,re.M).group(1))
        if m: covers[d]=m.group(1)
size=lambda u:int(subprocess.run(['curl','-s','-o','/dev/null','-w','%{size_download}','https://pixelstomotors.com'+u],capture_output=True,text=True).stdout)
sizes=[size(u) for u in covers.values()]
print(len(sizes), sum(sizes), sum(sizes)//len(sizes), min(sizes), max(sizes))
first=[375,336,329,324,323,320,319,318,316,315,314,312]
print('first12 old', sum(size(covers[l]) for l in first), 'new html', sum(os.path.getsize(f'public/thumbs/LOG{l}.html') for l in first))
new=[os.path.getsize(f'public/thumbs/LOG{i["log"]}.html') for i in json.load(open('tools/deep_dive_thumbs.json'))['items']]
print('new thumbs', len(new), 'total', sum(new), 'avg', sum(new)//len(new))
```

### 出力：重さを数えた結果

1行目は Gemini の絵67枚の本数・合計・平均・最小・最大（バイト）です。

```text
67 17333242 258705 89322 2913069
first12 old 10184622 new html 29207
new thumbs 67 total 162961 avg 2432
```

### コード：公開した考察記事の一覧で、実際に読み込まれた量を数えたスクリプト

公開したあとの考察記事の一覧を開き、読み込まれた書体のファイルとサムネイルの HTML と画像の量を数えました。

```python
from pathlib import Path
from playwright.sync_api import sync_playwright
exe = next((str(p) for p in Path("/opt/pw-browsers").glob("chromium-*/chrome-linux/chrome")), None)
with sync_playwright() as pw:
    b=pw.chromium.launch(executable_path=exe) if exe else pw.chromium.launch()
    p=b.new_page(viewport={'width':1280,'height':1000})
    seen={}
    def fin(req):
        r=req.response()
        if not r: return
        try:
            u=req.url
            if 'fonts.gstatic' in u or '/thumbs/' in u or '/media/' in u:
                seen[u]=len(r.body())
        except Exception: pass
    p.on('requestfinished', fin)
    p.goto('https://pixelstomotors.com/series5?p2m-notrack=1', wait_until='networkidle'); p.wait_for_timeout(6000)
    f=[v for u,v in seen.items() if 'gstatic' in u]; t=[v for u,v in seen.items() if '/thumbs/' in u]; m=[v for u,v in seen.items() if '/media/' in u]
    print('unique font files',len(f),sum(f)); print('thumb html',len(t),sum(t)); print('media',len(m),sum(m))
    b.close()
```

### 出力：公開した一覧で読み込まれた量

一覧の外で隠れているカードの分も一部読み込まれるため、サムネイルの HTML は36本分でした。Gemini の絵（/media/）は0です。

```text
unique font files 61 2207664
thumb html 36 100784
media 0 0
```

### 出力：書き出しにかかった時間

サムネイル67本の書き出しと、SNS 用の画像10本の書き出しを測りました。

```text
$ python3 tools/deep_dive_thumb.py
書き出し 67 件 → public/thumbs/
秒 0.06

$ python3 tools/render_og.py LOG375 LOG336 LOG329 LOG324 LOG323 LOG320 LOG319 LOG318 LOG316 LOG315
og 10本 33.2 秒
```
