ことづて:人から人へ託されて運ばれる言葉
https://techc-hack1.vercel.app
ひとつの言葉が、別の誰かの言葉を生んでいく場所。
1日に1回、言葉が届く。読んで何かを思い出したら、自分の言葉を書く。 その言葉は明日、別の誰かに届く。仕組みはこれだけで、 コメントも、いいねも、フォローも、通知もない。
最初は偉人の言葉しかない。使われるほど、ユーザー自身が書いた言葉が プールに増えていく。
上の30秒で起きていること。言葉がひとつ届き、芥川龍之介 → のらねこ → 三日坊主 → こもれび と 4世代の来歴をたどる。自分の言葉を書いて託し、誰かが受け取る。 最後に、他人の軌跡へ「この言葉から、ことづてが生まれました」と現れる。
集めているのは名言ではなく、言葉と、それによって動いた自分のほうである。
| 名言アプリ | SNS | 日記アプリ | ことづて | |
|---|---|---|---|---|
| 集めるもの | 偉人の言葉 | 投稿・反応 | 出来事・感情 | 言葉 + それで自分がどう動いたか |
| 誰が読まれるか | 偉人のみ | 目立つ人 | 本人のみ | 誰でも |
| 言葉の行き先 | お気に入り一覧 | 流れて消える | 非公開の記録 | 次の誰かに届く |
自分の「当たり前」は、自分では見られない。見えるのは、違う考えに触れたときだけ。
このプロダクトは、母親と「掃除」で衝突した実体験から出発している。整理すると、それは掃除の問題ではなく、 「家 = 回復空間」という自分の前提と「家 = 共同生活の基盤」という母親の前提がズレていただけだった。 価値があったのは母親を理解したこと以上に、自分の前提が初めて目に見える形になったことであり、 その気づきは一人の内省からではなく、異なる考えを持つ他者に触れたから生まれた。
自分の中で考えすぎる人。人に気を使いすぎて、言いたいことを飲み込む人を想定している。 会議で違和感を持っても「まあいいか」で終わらせる、友達の悪口に否定も肯定もできず黙る、 SNS に書こうとして投稿する直前に消す——そういう場面を持っている人である。
その人に必要なのは、自分に宛てられた返信ではないと考えた。 自分に向けて言われると身構えるし、名前を出して表明した意見は引っ込めにくい。 必要なのは、自分と関係のないところで生きている他人の世界にふれることだった。
だからこのサービスでは、誰も自分に宛てて書けない構造にしている。 いいねも返信も作っていないのは、機能を削ったのではなく、 宛先が存在しないことのほうを価値として選んだためである。
このプロダクトの本質は個別機能ではなく、ひとつの循環である。
flowchart LR
A["① 言葉が届く<br/>(1日1回)"] -->|心が動いたら| B["② 自分の言葉を書く"]
B --> C["③ その言葉が<br/>誰かに届く"]
C --> D["④ 受け取った人の<br/>心が動く"]
D --> A
本を作るアプリではなく、影響が循環する場所として設計している。 蓄積(軌跡)は循環の結果として生まれるもので、目的ではない。
系譜をたどると、内容が受け継がれていないことがわかる。
| 何について書いているか | |
|---|---|
| 芥川龍之介 | 危険思想と常識の関係 |
| のらねこ | 自分が黙ってきたのは、常識のためではなく波風を避けたかっただけ |
| 三日坊主 | 会議で黙ることは、賛成したことになっていた |
| こもれび | 友達の悪口に黙っていた自分 |
芥川の主題は「危険思想」、こもれびの主題は「友達の悪口」で、共通する内容はない。 残っているのは「この言葉がなければ、この言葉は生まれなかった」という関係だけである。
これは偶然ではない。書く欄の問いを「感想」ではなく「読んで、自分の何を思い出しましたか」に した時点で、答えは必ず書き手の側の出来事になる。内容が変質するのは設計の帰結であって、 事故ではない。
そのため、ここで扱っているのは思想の伝播ではなく影響の循環だと考えている。 リツイートは内容が変わらずに広がるが、ここでは内容が変わらないと成立しない。 転送ボタンを置いていないのではなく、転送が原理的に成り立たない。
抽選プールにユーザーの言葉を入れるだけでは、循環はピッチの中にしか存在しない。 そこで2つを実装している。
受け取る側には、その言葉が何から生まれたかを添えて表示する。
「黙るのは中立じゃない、という一文で、先月の自分を思い出した。……」
— こもれび
この言葉は、こもれびさんが三日坊主さんの言葉に触れて書いたものです
│ 波風を立てたくない、が自分にもある。会議で違和感を持っても……
芥川龍之介 → のらねこ → 三日坊主 → こもれび
書いた側には、それが誰かに渡ったことを見せる。
あなたの言葉を、今日 4 人が受け取りました
そして、自分の言葉を親にして誰かが書いたとき、わたしの軌跡にその言葉が現れる。
この言葉から、ことづてが生まれました
│ 波風を立てたくない、が自分にもある。会議で違和感を…… — 三日坊主さん
「受け取りました」は到達した数だが、こちらは自分の言葉が誰かの考えを動かした証拠にあたる。 返信ではない。書いた人はこちらに宛てておらず、こちらから返す手段もない。 返ってくるのは、動かしたという事実だけである。
思想家を「有名な人」ではなく「誰かの考えを変えた人」と定義するなら、 その条件を満たした瞬間がこの画面に出ていることになる。
溜まることではなく、渡ることが嬉しい設計にしている。
| 画面 | 内容 | |
|---|---|---|
| ホーム | / |
今日のことづて(来歴つき)・書く・「あなたの言葉を◯人が受け取りました」 |
| 2番目 | /trace |
わたしの軌跡(届いた言葉と、それに対して書いた言葉) |
| 3番目 | /others |
他の人の軌跡を読む |
| デモ用 | /demo |
別の人として開く・その場で1周させる(ALLOW_REROLL=true のときだけ) |
flowchart TB
subgraph browser["ブラウザ"]
UI["React Server Components<br/>+ Client Components"]
end
subgraph vercel["Next.js (App Router) / Vercel"]
PAGE["Server Components<br/>page.tsx"]
ACT["Server Actions<br/>app/actions.ts"]
Q["データアクセス<br/>lib/queries.ts"]
end
subgraph db["Postgres (Supabase)"]
W[("words")]
U[("users")]
D[("deliveries")]
end
UI -->|"受け取る / 託す"| ACT
PAGE --> Q
ACT --> Q
Q --> W
Q --> U
Q --> D
SEED["data/words.seed.json<br/>偉人の言葉 48件"] -.->|"npm run db:seed"| W
APIサーバーを別に立てず、Server Components と Server Actions でフロントとバックエンドを1プロジェクトに収めている。
ブラウザからDBへの直接アクセスはなく、SQL はすべてサーバー側にある。
本編のクエリは読み書きとも lib/queries.ts に集約し、Server Actions からは名前のついた関数として呼ぶ
(デモ用画面 app/demo/ だけは、そこでしか使わないクエリを同居させている)。
erDiagram
users ||--o{ words : "書く"
users ||--o{ deliveries : "受け取る"
words ||--o{ deliveries : "届けられる"
words ||--o{ words : "parent_word_id(来歴)"
users {
uuid id PK
text nickname
}
words {
uuid id PK
text text
text author
text source_type "classic / user"
text source "出典(偉人)"
text source_url
text original "原文(訳出したもの)"
uuid author_user_id FK "書いた人(ユーザーの言葉)"
uuid parent_word_id FK "この言葉を生んだ言葉"
}
deliveries {
uuid id PK
uuid user_id FK
uuid word_id FK
timestamptz delivered_at
}
この構造が引き受けていること。
-
偉人とユーザーが、同じ
wordsテーブルに同じ形式で並ぶ。 「思想家とは有名人ではなく、誰かの考えを変えた人である」という考えを、 データの形のほうに置いた。分けて持つと、その時点で両者は別の存在になる。 抽選も1つのテーブルを引くだけで済み、比率はCLASSIC_RATIOで調整する。 -
parent_word_idが来歴を保持する。血統や継承ではなく、 「この言葉がなければ、この言葉は生まれなかった」だけを記録している。 ニーチェ → Aさん → Bさん → あなた、という連なりを再帰クエリ1回でたどれる (getWordWithLineage)。 -
deliveriesが3つの役割を兼ねる。unique (user_id, word_id)で、同じ言葉を同じ人に二度届けない- 「あなたの言葉を◯人が受け取りました」を数える
- 循環が実際に回っている証跡になる
軌跡の1点は独立したテーブルを持たない。自分が書いた言葉と、その parent_word_id で
完全に表せるため、words から導出している。
偉人の言葉48件は、すべてパブリックドメインの原典から手作業で採取している。 生成AIに名言を作らせたものは一件も含まれていない。
「偉人の名言」は、インターネット上で誤帰属や捏造がとりわけ多いジャンルである。 出典を追えない引用を混ぜた時点で、プロダクト全体の信頼性が落ちる。そのため、
- 原典の本文を実際に取得する
- 出典URL(
source_url)をデータに保持する — 全48件 - 原典から訳出・現代語化したものは原文(
original)を併記する — 27件
の3点により、すべての言葉を検証可能な状態に保っている。 収録元は青空文庫・Project Gutenberg・中文Wikisource。原著者と翻訳者の双方について保護期間満了を確認済み。
漢文・英文・文語のように、言語や時代が読解の壁になるものは現代日本語に訳している。 訳出にはAIの支援を受けたが、原文を必ず併記しているので照合できる。 存在しない引用を作らせることと、実在する原文を移すことは別のもので、前者は行っていない。
なお、アプリの実行時にAIは呼び出していない。配信も抽選も来歴の表示も、すべてSQLだけで動く。
詳細は docs/sources.md。
-
機能の集合ではなく、ひとつの循環として設計した。 受け取る・書く・届く・また誰かが受け取る、が1本の輪になっている。
-
報酬を「溜まること」ではなく「渡ること」に置いた。 抽選プールに入れるだけでは循環はピッチの中にしか存在しない。「届いた」を可視化して初めてユーザーが輪を体験できる、という設計判断。
-
あえて解釈させない。 「あなたはこういう人です」と分析する実装のほうが簡単だが、それをやらない。気づきの所有権をユーザーに残すため、AIによる前提の抽出は入れていない。
-
偉人とユーザーを同じテーブルに置いた。 データ構造そのものが「誰もが思想家になりうる」という思想を表現している。
-
議論の構造を持ち込まなかった。 コメントも返信もいいねもない。SNS的な対立と承認から意図的に距離を置いた。
-
引用の正しさをデータ構造で担保した。
source_urlとoriginalを必須の設計要素として持ち、誤帰属が混入しない状態にした。 -
循環が見える状態をデモ用データとして再現可能にした。 機能が正しく動いていても、データがなければ来歴も到達件数も画面に出ない。
scripts/demo.tsが12人・13件の言葉と配信履歴を投入し、/demoから循環をその場で1周させられる。 系譜は3本ある(芥川龍之介→のらねこ→三日坊主→こもれび の4世代、三木清→すずめ→ことりん、太宰治→ゆず→たんぽぽ)。 1本だけだと手で作った1例に見えるため、複数の連なりが同時に成立していることを見せている。 -
最初の1語と、言葉の巡りを運任せにしない。 初めて受け取る言葉は来歴のあるものを優先する。比率が偉人7:ユーザー3である以上、 初対面の7割は来歴なしになり、このプロダクトの新規性が最初の1語で見えないままになるため。 また抽選は配信回数の少ない言葉を優先し(同数の中では無作為)、 プールが増えてもすべての言葉がいつか誰かに届くことを保証している。
| 領域 | 選択 | 理由 |
|---|---|---|
| フレームワーク | Next.js 16 (App Router) | Server Components / Server Actions でフロントとバックを1プロジェクトに収める |
| 言語 | TypeScript | |
| スタイリング | Tailwind CSS v4 | 短時間で見た目を整えられる |
| データ保存 | Postgres (Supabase) | 他の人の軌跡を読むために共有が必要。ローカル開発は素の Postgres で同じスキーマが動く |
| DBアクセス | postgres.js | ORM を挟まず、SQL をそのまま読める形で残す |
| デプロイ | Vercel |
AI は使っていない。「システムが解釈しない」という方針と、AIに前提を言語化させる設計が矛盾するため。
npm installSupabase を使う場合
- supabase.com でプロジェクトを作る
- Project Settings → Database → Connection string から接続文字列を取得する
- Vercel から使う場合は Session pooler(5432番)か Transaction pooler(6543番)を選ぶ
ローカルの Postgres を使う場合
createdb kotozutecp .env.example .env.local.env.local を編集する。
| 変数 | 意味 |
|---|---|
DATABASE_URL |
接続先の Postgres |
CLASSIC_RATIO |
抽選プールに占める偉人の言葉の比率(既定 0.7 = 偉人7:ユーザー3) |
ALLOW_REROLL |
true で1日1回の制限を外し、/demo を開く。本番は false |
TIMEZONE |
「1日」の区切りの判定に使う(既定 Asia/Tokyo) |
npm run db:seed # スキーマ適用 + 偉人の言葉48件(何度実行してもよい)
npm run db:demo # デモ用データ(12人・3本の系譜 + 配信履歴)Node を使わずに投入する場合(Supabase の SQL Editor に貼るだけ)
同じ内容を素の SQL として書き出してある。上から順に実行する。
| ファイル | 内容 |
|---|---|
db/schema.sql |
テーブルとインデックス |
db/seed.sql |
偉人の言葉48件 |
db/demo.sql |
デモ用データ(12人・3本の系譜 + 配信履歴) |
コピーは GitHub の Raw 表示から行うこと。 通常のファイル表示は長いファイルを仮想スクロールするため、全選択しても末尾まで取れないことがある。 途中で切れた SQL を貼ると、文字列リテラルが途中で終わり、英文の一部がテーブル名として解釈されて
relation "an" does not existのようなエラーになる。seed.sqlとdemo.sqlはトランザクションで囲んであるので、切れた場合は何も入らずに巻き戻る。
3つとも何度実行しても同じ結果になる。db/seed.sql と db/demo.sql は
data/words.seed.json と scripts/demo-data.ts から npm run db:export で生成しているため、
元データを変えたら再生成すること。
収録テキストを入れ替えたときは、作り直しが必要。
db/seed.sqlは「同じ本文がなければ入れる」方式なので、本文を書き換えた言葉は別の行として追加され、 古い版が残ってしまう。削除した言葉も消えない。 その場合はdb/reset.sqlを先に実行してから、schema → seed → demo をやり直す。 テーブルを作り直すと RLS の設定も消えるので、有効にし直すこと(db/reset.sqlの冒頭に手順がある)。
npm run dev| コマンド | 内容 |
|---|---|
npm run dev |
開発サーバー |
npm run build |
本番ビルド |
npm run db:seed |
スキーマ適用 + 偉人の言葉を投入 |
npm run db:demo |
デモ用データを投入(毎回作り直す) |
npm run db:export |
db/seed.sql と db/demo.sql を生成し直す |
npm run db:reset |
全テーブルを削除(SQLだけで行う場合は db/reset.sql) |
- Vercel でこのリポジトリをインポートする(ビルド設定は既定のままでよい)
- Environment Variables に
DATABASE_URL/CLASSIC_RATIO/ALLOW_REROLL/TIMEZONEを設定する - DB にまだ何も入れていなければ、上の「4. スキーマと言葉を投入する」を接続先の DB に対して実行する
発表に使うインスタンスでは
ALLOW_REROLL=trueにする。falseのままだと1日1回しか言葉を受け取れず、/demoも 404 になるため、 リハーサルも当日の実演もできなくなる。1日1回の制限を本来の形で見せたい場合だけfalseにする。
DATABASE_URL は Vercel から到達できる必要がある。Supabase の場合は Session pooler(5432番)か
Transaction pooler(6543番)の接続文字列を使う。6543番のときは prepared statement が使えないため、
lib/db.ts がポート番号を見て自動で無効化する。
/demoで「セッションを捨てる」→ 初回訪問の状態にする/demoで 「次に受け取る言葉を、系譜の深いものにする」 を押しておく- 通常の抽選は 偉人7 : ユーザー3 なので、来歴つきの言葉が一発で出るとは限らない。 予約しておくと、ホームの「受け取る」から普段どおりの演出のまま4世代の系譜が出る
- ホームで名前を入れて「はじめる」
- 「受け取る」 — 言葉が浮かび上がる
- 来歴が見える(芥川龍之介 → のらねこ → 三日坊主 → こもれび)
- その場で心が動いたことを書き、「ことづてを託す」
/demoの「自分の最新の言葉を、他の全員に届ける」で循環を1周させる- 通常は相手がアプリを開いた時に抽選で届く。発表の場でそれを待てないため、同じ配信処理をその場で起こす。作られるデータは通常の配信とまったく同じ
- ホームに戻ると 「あなたの言葉を、今日◯人が受け取りました」
/othersで他の人の軌跡を開く
話す内容・時間配分・事故対策は docs/demo-script.md に10分の台本としてまとめてある。 スライドは https://techc-hack1.vercel.app/slides.html(本番から配信している)。 ファイルは public/slides.html の1枚で、ブラウザで開くだけでも動く。
- docs/concept.md — 企画書(背景・仮説・設計判断・やらないこと)
- なかでも 考えが変わったところ — 作りながら変えた判断と、その理由
- docs/sources.md — 収録テキストの出典と著作権の確認
- docs/checklist.md — 当日やること(順番どおりに上から)
- docs/demo-script.md — 10分の発表台本(事前準備・話す内容・事故対策・想定質問)
- docs/talk.md — 読み上げ用の台本(口に出す文だけ・分量と話速を実測済み)
- public/slides.html — 発表スライド(1ファイル完結・タイマー内蔵・QRはその場で生成)
収録した偉人の言葉48件はすべてパブリックドメインの原典から採取しており、 第三者の著作権は関与しない(詳細は docs/sources.md)。
