光の言葉.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#183a4c">
<title>ひかりのことば PLUS | 聖書・祈り・学習BOT</title>
<style>
:root{--bg:#f4f4ee;--surface:#fff;--ink:#233546;--muted:#647586;--line:#e0e6e8;--deep:#183a4c;--deep2:#315a6d;--accent:#ae8950;--soft:#edf4f5;--shadow:0 12px 35px #1b364014;--bubble:#edf4f4}
body.dark{--bg:#101d28;--surface:#192c3a;--ink:#f2f3f0;--muted:#acbcc7;--line:#385061;--deep:#243f52;--deep2:#376177;--soft:#243c4b;--shadow:0 15px 40px #030b1080;--bubble:#253e4b;color-scheme:dark}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 system-ui,-apple-system,"Hiragino Kaku Gothic ProN","Yu Gothic",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #d0a86b;outline-offset:2px}a{color:inherit}.wrap{max-width:1250px;margin:auto;padding:20px 18px 50px}.hero{position:relative;overflow:hidden;border-radius:26px;background:linear-gradient(125deg,#112c40,#2f5868 75%,#56727a);color:white;padding:28px 32px;box-shadow:var(--shadow)}.hero:after{content:'✦';position:absolute;right:50px;top:-80px;font-size:260px;color:#fff1;pointer-events:none}.hero .kicker{letter-spacing:.23em;text-transform:uppercase;font-size:11px;color:#dfc99f}.hero h1{font-size:clamp(27px,4vw,42px);line-height:1.25;margin:8px 0}.hero p{margin:0;max-width:680px;color:#e4edef}.top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.top-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;z-index:2}.ghost{border:1px solid #ffffff77;background:#ffffff16;color:white;border-radius:11px;padding:8px 12px}.ghost:hover{background:#ffffff30}.shell{display:grid;grid-template-columns:255px minmax(0,1fr) 276px;gap:17px;margin-top:18px;align-items:start}.panel{background:var(--surface);border:1px solid var(--line);border-radius:19px;box-shadow:var(--shadow)}.left,.right{padding:20px}.panel h2{font-size:16px;margin:0 0 11px}.group-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.12em;color:var(--accent);margin:18px 0 9px}.nav{display:flex;flex-direction:column;gap:5px}.nav button{width:100%;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink);padding:11px 12px;text-align:left}.nav button:hover{background:var(--soft)}.nav button.active{background:var(--soft);border-color:var(--line);font-weight:800}.small{font-size:12px;color:var(--muted)}.notice{font-size:12px;color:var(--muted);line-height:1.85;padding:12px;background:var(--soft);border-radius:12px;margin-top:18px}.main{min-width:0}.view{display:none}.view.active{display:block}.view-header{padding:18px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:10px}.view-header h2{margin:0}.primary,.secondary,.chip{border-radius:11px;padding:10px 13px}.primary{background:var(--deep2);color:white;border:1px solid transparent;font-weight:700}.primary:hover{filter:brightness(1.12)}.secondary{background:var(--surface);border:1px solid var(--line);color:var(--ink)}.secondary:hover,.chip:hover{background:var(--soft)}.subtle{border:0;background:transparent;color:var(--muted);padding:6px}.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.column{display:flex;flex-direction:column;gap:13px}.chat-window{height:490px;overflow-y:auto;padding:19px;display:flex;flex-direction:column;gap:15px;background:linear-gradient(180deg,transparent,var(--soft))}.chat-row{max-width:91%;display:flex;flex-direction:column;align-items:flex-start;gap:4px}.chat-row.user{align-self:flex-end;align-items:flex-end}.bubble{padding:13px 16px;border-radius:16px;border:1px solid var(--line);background:var(--bubble);white-space:pre-wrap;overflow-wrap:anywhere}.chat-row.user .bubble{background:var(--deep2);border-color:transparent;color:#fff}.bubble small{display:block;margin-top:9px;border-top:1px solid var(--line);padding-top:7px;color:var(--muted)}.chat-row.user .bubble small{color:#fff}.stamp{color:var(--muted);font-size:10px;padding:0 5px}.composer{padding:15px;border-top:1px solid var(--line)}textarea,input,select{color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:12px;width:100%;min-width:0}textarea{resize:vertical;min-height:90px}.composer textarea{min-height:75px}.toolbar{display:flex;gap:8px;justify-content:space-between;align-items:center;margin-top:8px}.padded{padding:20px}.chips{display:flex;flex-wrap:wrap;gap:8px}.chip{border:1px solid var(--line);background:var(--surface);color:var(--ink);font-size:12px;padding:8px 11px;border-radius:99px}.quote{background:var(--soft);border-left:4px solid var(--accent);border-radius:0 12px 12px 0;padding:12px 14px;margin:11px 0}.quote p{margin:0}.quote small{display:block;color:var(--muted);margin-top:7px}.stat{display:grid;grid-template-columns:repeat(2,1fr);gap:9px}.stat .tile{background:var(--soft);padding:12px;border-radius:11px}.stat b{display:block;font-size:21px}.tile small{color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}.card{padding:15px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.card h3{font-size:15px;margin:0 0 7px}.card p{font-size:13px;color:var(--muted);margin:0 0 8px}.card .row{justify-content:space-between}.reading{padding:12px;border:1px solid var(--line);border-radius:12px;margin:8px 0;background:var(--surface)}.reading h3{font-size:14px;margin:0 0 7px}.reading p{font-size:13px;margin:0 0 10px}.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--soft);padding:3px 8px;border-radius:30px}.books{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:7px;max-height:380px;overflow:auto;padding:6px 0}.book{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:9px;text-align:left;border-radius:9px}.book:hover{background:var(--soft)}.tabs{display:flex;gap:7px;flex-wrap:wrap;margin:0 0 14px}.tabs button{border:1px solid var(--line);background:var(--surface);padding:8px 12px;border-radius:9px;color:var(--ink)}.tabs button.active{background:var(--deep2);color:white}.muted{color:var(--muted)}.hide{display:none!important}.quiz-option{display:block;width:100%;text-align:left;padding:12px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--ink);margin-top:8px}.quiz-option:hover{background:var(--soft)}.quiz-option.correct{border-color:#3da57a;background:#dcf8e8;color:#174c37}.quiz-option.wrong{border-color:#c96b65;background:#ffe9e7;color:#833028}.progress{height:9px;border-radius:30px;background:var(--line);overflow:hidden;margin:12px 0}.progress i{display:block;background:var(--accent);height:100%;transition:width .2s}.footer{text-align:center;color:var(--muted);font-size:12px;margin-top:24px}.right .reading{border:0;background:var(--soft)}.danger{color:#b74b52}.divider{border:0;border-top:1px solid var(--line);margin:18px 0}#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#16374b;color:#fff;padding:10px 16px;border-radius:12px;box-shadow:0 10px 40px #0004;z-index:10;display:none;max-width:95vw;text-align:center}@media(max-width:1080px){.shell{grid-template-columns:220px minmax(0,1fr)}.right{grid-column:1/-1}.right .stat{grid-template-columns:repeat(4,1fr)}}@media(max-width:700px){.shell{display:flex;flex-direction:column}.shell>*,.main{width:100%}.left{padding:13px}.nav{flex-direction:row;overflow-x:auto;white-space:nowrap}.nav button{width:auto;flex:0 0 auto}.group-label{display:none}.hero{padding:24px 20px}.top{flex-direction:column}.top-actions{justify-content:flex-start}.chat-window{height:440px}.right .stat{grid-template-columns:repeat(2,1fr)}.wrap{padding:12px}.view-header{padding:15px}.padded{padding:15px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header class="hero"><div class="top"><div><div class="kicker">WORDS OF LIGHT · PERSONAL STUDY</div><h1>✝ ひかりのことば <span style="font-size:.35em;vertical-align:middle;letter-spacing:.14em">PLUS</span></h1><p>聖書の学び、日々の祈り、疑問や悩みを静かに整理する、オフラインのキリスト教学習BOT。</p></div><div class="top-actions"><button class="ghost" id="themeButton">☾ 表示切替</button><button class="ghost" id="exportButton">⇩ データ保存</button></div></div></header>
<div class="shell">
<aside class="panel left"><h2>メニュー</h2><nav class="nav" aria-label="メインメニュー"><button data-view="chat" class="active">💬 聖書BOT</button><button data-view="study">📖 聖書を学ぶ</button><button data-view="prayer">🕊 祈りのノート</button><button data-view="quiz">✏️ 聖書クイズ</button><button data-view="saved">☆ 保存したことば</button><button data-view="settings">⚙ 設定・管理</button></nav><div class="group-label">おすすめの話題</div><div class="chips" id="quickTopics"><button class="chip" data-ask="神とは何ですか">神について</button><button class="chip" data-ask="イエスの教え">イエス</button><button class="chip" data-ask="赦しについて">赦し</button><button class="chip" data-ask="仕事で疲れました">疲れ</button><button class="chip" data-ask="不安で眠れません">不安</button><button class="chip" data-ask="今日のことば">今日のことば</button><button class="chip" data-ask="なぜ苦しみがあるの">苦しみ</button><button class="chip" data-ask="祈りを作って">祈り</button></div><div class="notice">このBOTはローカルの応答ルールを使っています。実在の牧師・神・イエスとして発言するものではなく、回答は信仰上の解釈の一例です。</div></aside>
<main class="main">
<section class="panel view active" id="view-chat"><div class="view-header"><div><h2>聖書のことば案内BOT</h2><div class="small">テーマに応じた説明と聖書箇所の案内</div></div><button id="clearChat" class="secondary">履歴消去</button></div><div id="chatLog" class="chat-window" role="log" aria-live="polite" aria-label="会話履歴"></div><form id="chatForm" class="composer"><label class="small" for="chatInput">メッセージを入力</label><textarea id="chatInput" maxlength="800" placeholder="例:希望について教えてください(Ctrl + Enterで送信)"></textarea><div class="toolbar"><span class="small" id="charCount">0 / 800</span><button class="primary" type="submit">送信 ↗</button></div></form></section>
<section class="panel view" id="view-study"><div class="view-header"><div><h2>聖書を学ぶ</h2><div class="small">入門テーマ・書巻一覧・聖句の要約検索</div></div></div><div class="padded"><h2>テーマ別ガイド</h2><div class="cards" id="guideCards"></div><hr class="divider"><h2>聖句のテーマを探す</h2><input id="studySearch" placeholder="例:愛、平和、希望、マタイ、ローマ" aria-label="聖句検索"><div id="studyResults" aria-live="polite"></div><hr class="divider"><h2>聖書66書巻</h2><p class="small">プロテスタントの一般的な書巻区分です。カトリック・正教会では収録書巻が異なります。書巻を選ぶと概要を表示します。</p><div class="tabs"><button id="oldButton" class="active" data-testament="old">旧約聖書(39)</button><button id="newButton" data-testament="new">新約聖書(27)</button></div><div id="bookGrid" class="books"></div><div id="bookDetail" class="reading" aria-live="polite">書巻を選択してください。</div></div></section>
<section class="panel view" id="view-prayer"><div class="view-header"><div><h2>祈りのノート</h2><div class="small">テーマ別の祈り例と、自分の記録</div></div></div><div class="padded column"><label>祈りのテーマ<select id="prayerTopic"><option value="gratitude">感謝</option><option value="peace">心の平安</option><option value="strength">疲れと回復</option><option value="wisdom">知恵・仕事・学び</option><option value="others">家族や大切な人</option><option value="forgiveness">赦し</option></select></label><button id="prayerGenerate" class="primary">祈りの例文を作る</button><label>祈りの文(編集できます)<textarea id="prayerText" rows="7" maxlength="3000" placeholder="自分のことばで書いてください"></textarea></label><div class="row"><button id="prayerSave" class="primary">ノートに保存</button><button id="prayerCopy" class="secondary">コピー</button></div><h2>祈りの記録</h2><div id="prayerHistory"></div><p class="small">自分の言葉で書いた祈りはこの端末のブラウザにのみ保存します。共有端末では注意してください。</p></div></section>
<section class="panel view" id="view-quiz"><div class="view-header"><div><h2>聖書クイズ</h2><div class="small">10問の入門クイズ・復習つき</div></div><button id="quizRestart" class="secondary">はじめから</button></div><div class="padded"><div class="row" style="justify-content:space-between"><b id="quizCount">第1問 / 10問</b><span id="quizScore" class="small">正解 0問</span></div><div class="progress"><i id="quizProgress" style="width:0"></i></div><h2 id="quizQuestion"></h2><div id="quizOptions"></div><div id="quizFeedback" class="reading hide" role="status"></div><button id="quizNext" class="primary hide">次の問題 →</button></div></section>
<section class="panel view" id="view-saved"><div class="view-header"><div><h2>保存したことば</h2><div class="small">気になった聖書のテーマを保存</div></div></div><div class="padded"><div id="savedItems"></div></div></section>
<section class="panel view" id="view-settings"><div class="view-header"><div><h2>設定・データ管理</h2><div class="small">端末内だけで完結します</div></div></div><div class="padded column"><div class="card"><h3>データのバックアップ</h3><p>会話履歴・祈りのノート・保存項目・設定をJSONファイルに保存できます。</p><div class="row"><button id="exportButton2" class="primary">JSONを書き出す</button><button id="importButton" class="secondary">JSONを読み込む</button><input type="file" id="importFile" accept=".json,application/json" class="hide"></div></div><div class="card"><h3>データの削除</h3><p>このアプリの保存内容をすべて削除します。元に戻せません。</p><button id="resetAll" class="secondary danger">すべて消去</button></div><div class="card"><h3>このアプリについて</h3><p>HTML・CSS・JavaScriptだけで動作する、学習・対話向けのキーワードBOTです。生成AIとの接続、ネット通信、会員登録機能はありません。返答内容は事前に設定した説明文です。保存データはブラウザ内にあり、ブラウザのデータ削除によって失われます。</p><p>聖書本文の逐語引用ではなく、趣旨を簡潔にまとめています。原文の確認や教派別の教義については、実際の聖書や信頼できる教会・専門資料を参照してください。</p></div></div></section>
</main>
<aside class="panel right"><h2>今日の聖書テーマ</h2><div class="reading"><b id="dailyName"></b><p id="dailyText" style="margin-top:8px"></p><span id="dailyRef" class="tag"></span><div class="row" style="margin-top:10px"><button id="saveDaily" class="secondary">☆ 保存する</button><button id="askDaily" class="secondary">話してみる</button></div></div><h2 style="margin-top:23px">学習の記録</h2><div class="stat"><div class="tile"><b id="statMessages">0</b><small>質問数</small></div><div class="tile"><b id="statSaved">0</b><small>保存項目</small></div><div class="tile"><b id="statPrayer">0</b><small>祈りの記録</small></div><div class="tile"><b id="statQuiz">0</b><small>クイズ最高点</small></div></div><p class="notice">つらい気持ちへの宗教的な励ましは、医療や緊急支援の代わりにはなりません。差し迫った危険がある場合は119などに連絡してください。</p></aside>
</div><footer class="footer">ひかりのことば PLUS · Offline Bible Study Companion · 聖句は要約で紹介しています</footer></div><div id="toast" role="status" aria-live="polite"></div>
<script>
(()=>{'use strict';
const KEY='hikari_plus_v2';const LEGACY='hikari_christian_bot_chat_v1';const $=id=>document.getElementById(id);const clamp=(n,a,b)=>Math.max(a,Math.min(b,n));
const library=[
{id:'love',title:'愛',ref:'コリントの信徒への手紙一 13章4〜7節',text:'愛は忍耐や親切さとして表れ、相手を大切にする態度につながると教えられます。',tags:'愛 思いやり 親切 優しさ',related:'ヨハネによる福音書 13章34節'},
{id:'rest',title:'休息',ref:'マタイによる福音書 11章28〜30節',text:'イエスは重荷を負う人々に向けて休息へ招く言葉を語りました。',tags:'疲れ 眠気 仕事 休息',related:'詩編 23編'},
{id:'hope',title:'希望',ref:'ローマの信徒への手紙 5章3〜5節',text:'苦難の中でも忍耐や経験を通して希望を見いだす、という考えが示されています。',tags:'希望 絶望 未来 勇気',related:'ローマの信徒への手紙 15章13節'},
{id:'anxiety',title:'不安と祈り',ref:'フィリピの信徒への手紙 4章6〜7節',text:'思い煩いを神に伝え、感謝をもって祈ることが勧められています。',tags:'不安 心配 怖い 祈り',related:'マタイによる福音書 6章25〜34節'},
{id:'peace',title:'平和',ref:'マタイによる福音書 5章9節',text:'平和をつくる人々への祝福が語られます。',tags:'平和 戦争 平安',related:'ローマの信徒への手紙 12章18節'},
{id:'neighbor',title:'隣人',ref:'マルコによる福音書 12章29〜31節',text:'神を愛することと、隣人を自分のように愛することが大切な教えとして示されています。',tags:'隣人 愛 関係 家族',related:'ルカによる福音書 10章25〜37節'},
{id:'forgive',title:'赦し',ref:'マタイによる福音書 18章21〜22節',text:'赦しの大切さを繰り返し教える場面があります。ただし不当な扱いを放置することとは異なります。',tags:'許し 赦し 罪 謝罪',related:'エフェソの信徒への手紙 4章32節'},
{id:'gratitude',title:'感謝',ref:'テサロニケの信徒への手紙一 5章16〜18節',text:'喜びと祈り、感謝を大切にする姿勢が勧められています。',tags:'感謝 ありがとう 喜び',related:'詩編 100編'},
{id:'wisdom',title:'知恵',ref:'ヤコブの手紙 1章5節',text:'知恵を必要とする人が神に願い求めることについて語られています。',tags:'知恵 学び 勉強 仕事',related:'箴言 3章5〜6節'},
{id:'mercy',title:'あわれみ',ref:'ルカによる福音書 10章25〜37節',text:'善いサマリア人のたとえでは、境界を越えて困っている人を助ける姿が描かれます。',tags:'慈悲 助ける 困っている人',related:'マタイによる福音書 25章35〜40節'},
{id:'creation',title:'創造',ref:'創世記 1章',text:'聖書の冒頭は神による世界の創造を語ります。受け止め方は教派や信者によって異なります。',tags:'創世記 世界 創造 宇宙',related:'詩編 8編'},
{id:'faith',title:'信仰',ref:'ヘブライ人への手紙 11章1節',text:'信仰について、希望するものや目に見えないものとの関わりを説明しています。',tags:'信仰 神 信じる',related:'マルコによる福音書 9章24節'},
{id:'compassion',title:'悲しみの中で',ref:'マタイによる福音書 5章4節',text:'悲しむ人々への慰めが、山上の説教の一部として語られます。',tags:'悲しい 孤独 喪失 涙',related:'詩編 34編18節'},
{id:'truth',title:'真理',ref:'ヨハネによる福音書 8章31〜32節',text:'真理を知ることと自由についての教えが語られます。',tags:'真理 自由 生き方',related:'ヨハネによる福音書 14章6節'},
{id:'justice',title:'正義',ref:'ミカ書 6章8節',text:'正義を行い、あわれみを愛し、へりくだって歩むことが勧められます。',tags:'正義 公平 悪 善悪',related:'イザヤ書 1章17節'},
{id:'time',title:'時',ref:'コヘレトの言葉 3章1〜8節',text:'人生のさまざまな出来事には、それぞれ時があるという詩的な表現があります。',tags:'時間 人生 焦り',related:'詩編 90編12節'}
];
const guides=[{name:'キリスト教の中心',desc:'イエス・キリスト、福音書、隣人愛を知る',refs:['マルコによる福音書 1章','ヨハネによる福音書 3章16節']},{name:'祈りの基本',desc:'感謝や願いを言葉にし、主の祈りに触れる',refs:['マタイによる福音書 6章5〜13節']},{name:'旧約から新約へ',desc:'創造、預言者、福音書、初代教会の流れ',refs:['創世記 1章','ルカによる福音書 1章','使徒言行録 2章']},{name:'愛と行動',desc:'愛・親切・隣人への配慮を学ぶ',refs:['コリントの信徒への手紙一 13章','ルカによる福音書 10章25〜37節']},{name:'苦難と希望',desc:'苦しみを単純化せず、希望の言葉を探す',refs:['ヨブ記 1章','ローマの信徒への手紙 5章3〜5節']},{name:'平和と正義',desc:'平和づくり、公平さ、弱い立場の人への配慮',refs:['マタイによる福音書 5章9節','ミカ書 6章8節']}];
const oldBooks='創世記,出エジプト記,レビ記,民数記,申命記,ヨシュア記,士師記,ルツ記,サムエル記上,サムエル記下,列王記上,列王記下,歴代誌上,歴代誌下,エズラ記,ネヘミヤ記,エステル記,ヨブ記,詩編,箴言,コヘレトの言葉,雅歌,イザヤ書,エレミヤ書,哀歌,エゼキエル書,ダニエル書,ホセア書,ヨエル書,アモス書,オバデヤ書,ヨナ書,ミカ書,ナホム書,ハバクク書,ゼファニヤ書,ハガイ書,ゼカリヤ書,マラキ書'.split(',');
const newBooks='マタイによる福音書,マルコによる福音書,ルカによる福音書,ヨハネによる福音書,使徒言行録,ローマの信徒への手紙,コリントの信徒への手紙一,コリントの信徒への手紙二,ガラテヤの信徒への手紙,エフェソの信徒への手紙,フィリピの信徒への手紙,コロサイの信徒への手紙,テサロニケの信徒への手紙一,テサロニケの信徒への手紙二,テモテへの手紙一,テモテへの手紙二,テトスへの手紙,フィレモンへの手紙,ヘブライ人への手紙,ヤコブの手紙,ペトロの手紙一,ペトロの手紙二,ヨハネの手紙一,ヨハネの手紙二,ヨハネの手紙三,ユダの手紙,ヨハネの黙示録'.split(',');
const bookNotes={'創世記':'天地創造、族長たちの物語から始まる書巻。','出エジプト記':'モーセと出エジプト、契約の物語。','詩編':'賛美、祈り、嘆き、感謝などの詩歌。','箴言':'日常の判断と知恵を扱う格言集。','ヨブ記':'正しい人の苦しみと神への問いを扱う文学作品。','イザヤ書':'預言、裁き、慰めと希望を語る書巻。','マタイによる福音書':'イエスの生涯と教えを伝える四福音書の一つ。','マルコによる福音書':'イエスの活動を簡潔に描く福音書。','ルカによる福音書':'イエスの生涯を伝え、弱い立場への配慮も描く福音書。','ヨハネによる福音書':'独特の語り口でイエスの存在と教えを伝える福音書。','使徒言行録':'初代教会と宣教の広がりを伝える書巻。','ローマの信徒への手紙':'信仰、恵み、共同体の生き方を論じる書簡。','ヨハネの黙示録':'象徴表現で苦難と終末の希望を描く黙示文学。'};
const prayers={gratitude:'神さま、今日という日を与えてくださりありがとうございます。小さな恵みに目を向け、周りの人への感謝を忘れずに過ごせますように。アーメン。',peace:'神さま、心が落ち着かないとき、深呼吸して目の前の一歩を見つめられるよう助けてください。安心して休める時間と、必要な支えを与えてください。アーメン。',strength:'神さま、疲れている私に休息と回復の時を与えてください。無理を重ねず、身体と心を大切にする知恵をください。アーメン。',wisdom:'神さま、今日の学びや仕事に必要な知恵を与えてください。焦らず、一つずつ丁寧に進められますように。アーメン。',others:'神さま、家族や大切な人たちを覚えます。互いを思いやり、必要な助けを分かち合えますように。アーメン。',forgiveness:'神さま、自分の過ちを見つめ直す勇気と、正しい行いへ進む知恵をください。傷ついた人の尊厳と安全も守られますように。アーメン。'};
const quiz=[
{q:'キリスト教の中心となる人物は?',a:['モーセ','イエス・キリスト','ダビデ','パウロ'],right:1,ex:'キリスト教ではイエスを救い主として信じます。'},
{q:'聖書の冒頭にある書巻は?',a:['創世記','詩編','マタイによる福音書','出エジプト記'],right:0,ex:'創世記は天地創造などから始まります。'},
{q:'イエスの生涯を伝える「福音書」は何書ありますか?',a:['2','3','4','5'],right:2,ex:'マタイ、マルコ、ルカ、ヨハネの4つです。'},
{q:'「善いサマリア人」のたとえが登場するのは?',a:['ルカによる福音書','創世記','黙示録','詩編'],right:0,ex:'ルカ10章に登場し、隣人愛を考える話です。'},
{q:'モーセがエジプトから民を導く物語は主にどの書巻?',a:['箴言','出エジプト記','ヨブ記','使徒言行録'],right:1,ex:'出エジプト記に書かれています。'},
{q:'詩編に多い表現はどれ?',a:['賛美と祈り','法律条文だけ','旅行案内','建築手順だけ'],right:0,ex:'詩編には賛美、嘆き、感謝などが含まれます。'},
{q:'「愛は忍耐強い」などの教えがある書簡は?',a:['ヤコブの手紙','コリントの信徒への手紙一','ローマの信徒への手紙','ユダの手紙'],right:1,ex:'コリント一13章は愛についてよく読まれます。'},
{q:'初代教会の歩みを描く書巻は?',a:['使徒言行録','レビ記','雅歌','コヘレトの言葉'],right:0,ex:'使徒言行録は初代教会の活動を伝えます。'},
{q:'旧約聖書に含まれるのは?',a:['マルコによる福音書','ヨブ記','ヨハネの手紙一','ローマの信徒への手紙'],right:1,ex:'ヨブ記は旧約聖書の知恵文学です。'},
{q:'山上の説教を収めている福音書は?',a:['マタイによる福音書','使徒言行録','ダニエル書','ガラテヤの信徒への手紙'],right:0,ex:'マタイ5〜7章で知られます。'}
];
const defaults=()=>({chat:[],saved:[],prayers:[],best:0,dark:false});let state=defaults();
function safeLoad(){try{const x=JSON.parse(localStorage.getItem(KEY)||'null');if(x&&typeof x==='object'){state.chat=Array.isArray(x.chat)?x.chat.filter(m=>m&&['user','bot'].includes(m.role)&&typeof m.text==='string').slice(-120):[];state.saved=Array.isArray(x.saved)?x.saved.filter(v=>typeof v==='string').slice(0,100):[];state.prayers=Array.isArray(x.prayers)?x.prayers.filter(p=>p&&typeof p.text==='string').slice(-100):[];state.best=clamp(Number(x.best)||0,0,10);state.dark=x.dark===true;return;}}catch(e){}try{const prev=JSON.parse(localStorage.getItem(LEGACY)||'[]');if(Array.isArray(prev))state.chat=prev.filter(m=>m&&['user','bot'].includes(m.role)&&typeof m.text==='string').slice(-120).map(m=>({role:m.role,text:m.text,ref:m.ref||'',at:Date.now()}));}catch(e){}}
let warned=false;function persist(){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){if(!warned){toast('保存容量が不足しています。バックアップを取ってください。');warned=true;}}}
let toastTimer;function toast(s){$('toast').textContent=s;$('toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').style.display='none',3500)}
function el(tag,cls,text){const n=document.createElement(tag);if(cls)n.className=cls;if(text!==undefined)n.textContent=text;return n;}
function jump(view){document.querySelectorAll('.view').forEach(n=>n.classList.toggle('active',n.id==='view-'+view));document.querySelectorAll('[data-view]').forEach(n=>n.classList.toggle('active',n.dataset.view===view));if(view==='saved')renderSaved();if(view==='prayer')renderPrayers();if(view==='study')renderSearch();window.scrollTo({top:0,behavior:'smooth'});}
function stat(){ $('statMessages').textContent=state.chat.filter(x=>x.role==='user').length;$('statSaved').textContent=state.saved.length;$('statPrayer').textContent=state.prayers.length;$('statQuiz').textContent=state.best;}
function addChat(role,text,ref=''){state.chat.push({role,text,ref,at:Date.now()});state.chat=state.chat.slice(-120);persist();renderChat();stat();}
function renderChat(){const root=$('chatLog');root.replaceChildren();for(const m of state.chat){const row=el('div','chat-row '+m.role);const bubble=el('div','bubble',m.text);if(m.ref){bubble.appendChild(el('small','',`参考:${m.ref}`));}row.appendChild(bubble);row.appendChild(el('span','stamp',m.role==='bot'?'ひかりのことば':'あなた'));root.appendChild(row);}root.scrollTop=root.scrollHeight;}
const risky=/(死にたい|自殺|自傷|命を絶|消えてしまいたい|殺したい|人を傷つけたい)/;
const patterns=[
{p:/祈り|祈る|祈祷|お祈り/,id:'anxiety',answer:'祈りは神に心を向け、感謝や願い、悩みを言葉にする営みです。立派な言葉でなくても構いません。祈りの例文を作るには「祈りのノート」を開いてください。',ref:'マタイによる福音書 6章5〜13節'},
{p:/イエス|キリスト|救世主|復活/,id:'neighbor',answer:'イエス・キリストはキリスト教の中心となる人物です。四福音書はその生涯、教え、十字架と復活について伝えています。歴史上の人物としての研究と、信仰上の受け止め方は区別して考えられます。',ref:'マルコによる福音書 1章/ヨハネによる福音書 20章'},
{p:/三位一体|聖霊|父なる神/,id:'faith',answer:'多くのキリスト教教派では、父・子・聖霊の三位一体を重要な教理として理解します。詳しい説明や表現には教派間の違いがあります。',ref:'マタイによる福音書 28章19節'},
{p:/神とは|神様|神さま|信仰|教会|宗教/,id:'faith',answer:'キリスト教は神の愛や恵み、イエス・キリストへの信仰を中心としています。疑問を持ちながら学ぶこともできます。教派によって解釈や礼拝の形は異なります。',ref:'マルコによる福音書 12章29〜31節'},
{p:/苦しみ|苦難|なぜ悪|理不尽|不幸/,id:'compassion',answer:'「なぜ苦しみがあるのか」は聖書でも簡単な答えが出ない大きな問いです。ヨブ記は正しい人の苦しみを扱い、福音書には悲しむ人への慰めの言葉があります。苦しみを本人の信仰不足のせいにする必要はありません。',ref:'ヨブ記/マタイによる福音書 5章4節'},
{p:/不安|怖い|恐怖|心配|緊張|眠れ/,id:'anxiety',answer:'不安なとき、気持ちを無理に押さえ込む必要はありません。フィリピ書には心配を祈りのうちに伝える教えがあります。睡眠や体調の問題が続く場合は、休息や医療的な支援も大切です。',ref:'フィリピの信徒への手紙 4章6〜7節'},
{p:/疲れ|仕事|しんどい|きつい|休み|休息/,id:'rest',answer:'イエスは重荷を負う人に休息を招く言葉を語りました。休むことは怠けることと同じではありません。今日は食事、水分、睡眠など身体の必要も大切にしてください。',ref:'マタイによる福音書 11章28〜30節'},
{p:/赦|許し|罪|後悔|謝罪/,id:'forgive',answer:'赦しと悔い改めはキリスト教の大きなテーマです。過ちに向き合うことは大切ですが、赦しは被害の否定でも、危険な状況に戻る義務でもありません。',ref:'マタイによる福音書 18章21〜22節'},
{p:/愛|恋|思いやり|やさしさ|優しさ/,id:'love',answer:'新約聖書では、愛は相手に対する忍耐、親切、尊重など、日々の行いに表れるものとして語られます。自分を大切にする視点も忘れないでください。',ref:'コリントの信徒への手紙一 13章4〜7節'},
{p:/希望|明日|絶望|未来|勇気/,id:'hope',answer:'ローマ書は苦難の中で育まれる希望を語ります。希望とは常に明るくいることではなく、助けを求めたり小さな一歩を選んだりすることも含むと思います。',ref:'ローマの信徒への手紙 5章3〜5節'},
{p:/平和|戦争|争い|喧嘩/,id:'peace',answer:'山上の説教には平和をつくる人への祝福があります。平和は争いを見ないふりすることではなく、尊厳や公平を守る行いとも関わります。',ref:'マタイによる福音書 5章9節'},
{p:/家族|友達|隣人|人間関係|孤独/,id:'neighbor',answer:'イエスは隣人を大切にすることを強調しました。善いサマリア人の話は、相手の立場にかかわらず手を差し伸べることを考えさせます。',ref:'ルカによる福音書 10章25〜37節'},
{p:/感謝|ありがとう|喜び/,id:'gratitude',answer:'聖書は祈りや感謝を大切にするよう勧めます。つらい日まで楽しいふりをする必要はありません。今日ありがたかったことを一つ思い出すところからでもよいでしょう。',ref:'テサロニケの信徒への手紙一 5章16〜18節'},
{p:/創造|宇宙|世界|地球|創世記/,id:'creation',answer:'創世記の冒頭は世界の創造を語ります。現代の科学が扱う「どのように」と信仰が問う「なぜ」をどう関係づけるかは、キリスト教徒の中でも複数の立場があります。',ref:'創世記 1〜2章'},
{p:/勉強|学び|知恵|判断|選択/,id:'wisdom',answer:'知恵を求めることは、聖書の重要なテーマの一つです。考えたり学んだり、必要なときに周囲に相談したりする姿勢も大切でしょう。',ref:'ヤコブの手紙 1章5節'},
{p:/今日|聖書のことば|一節|聖句/,id:'daily',answer:'今日のテーマを案内します。',ref:''}
];
function respond(text){if(risky.test(text))return {text:'とても切迫した内容ですね。もし今、自分や誰かを傷つけそうなら、危険な物から離れ、近くにいる人や医療者へ今すぐ伝えてください。日本で緊急の危険がある場合は119(救急)や110(警察)を利用できます。宗教的な話だけで抱える必要はありません。',ref:''};
if(/^(こんにちは|こんばんは|おはよう|やあ)[!!。\s]*$/.test(text))return{text:'こんにちは。今日は何について考えてみましょうか。祈り、イエス、希望、疲れ、聖書の疑問など、気になるテーマをどうぞ。',ref:''};
const p=patterns.find(x=>x.p.test(text));if(p){if(p.id==='daily'){const item=dailyItem();return{text:`今日のテーマは「${item.title}」です。\n${item.text}`,ref:item.ref};}return{text:p.answer,ref:p.ref};}
const keys=library.filter(item=>text.includes(item.title)||item.tags.split(' ').some(t=>t.length>=2&&text.includes(t))).slice(0,2);if(keys.length){return {text:keys.map(x=>`${x.title}:${x.text}`).join('\n\n'),ref:keys.map(x=>x.ref).join('/')};}
return{text:'お話ししてくださってありがとうございます。私は事前に用意された聖書のテーマから案内するBOTです。質問の意味を十分には理解できないことがあります。「愛」「祈り」「不安」「イエス」「赦し」「希望」など、話題を短い言葉で尋ねてみてください。',ref:''};}
function send(raw){const text=String(raw||'').trim().slice(0,800);if(!text)return;addChat('user',text);const r=respond(text);addChat('bot',r.text,r.ref);$('chatInput').value='';updateCount();}
function updateCount(){$('charCount').textContent=$('chatInput').value.length+' / 800';}
function dailyItem(){const d=new Date();const stamp=Date.UTC(d.getFullYear(),d.getMonth(),d.getDate());return library[Math.floor(stamp/86400000)%library.length];}
function renderDaily(){const d=dailyItem();$('dailyName').textContent=d.title;$('dailyText').textContent=d.text;$('dailyRef').textContent=d.ref;$('saveDaily').textContent=state.saved.includes(d.id)?'★ 保存済み':'☆ 保存する';}
function toggleSaved(id){const exists=state.saved.indexOf(id);if(exists>=0)state.saved.splice(exists,1);else state.saved.push(id);persist();stat();renderDaily();renderSaved();renderSearch();toast(exists>=0?'保存を解除しました':'ことばを保存しました');}
function renderSaved(){const root=$('savedItems');root.replaceChildren();const found=state.saved.map(id=>library.find(x=>x.id===id)).filter(Boolean);if(!found.length){root.appendChild(el('p','muted','保存したことばはまだありません。「今日の聖書テーマ」や検索結果から追加できます。'));return;}for(const x of found)root.appendChild(libraryCard(x));}
function libraryCard(x){const box=el('div','reading');box.append(el('h3','',x.title),el('p','',x.text),el('span','tag',x.ref));const row=el('div','row');row.style.marginTop='10px';const button=el('button','secondary',state.saved.includes(x.id)?'★ 保存解除':'☆ 保存');button.onclick=()=>toggleSaved(x.id);const ask=el('button','secondary','BOTに質問');ask.onclick=()=>{jump('chat');send(x.title+'について教えて');};row.append(button,ask);box.appendChild(row);return box;}
function renderSearch(){const root=$('studyResults');root.replaceChildren();const q=$('studySearch').value.trim().toLowerCase();const result=library.filter(x=>!q||(x.title+' '+x.ref+' '+x.tags+' '+x.text).toLowerCase().includes(q));root.appendChild(el('p','small',`${result.length}件のテーマ`));if(!result.length)root.appendChild(el('p','muted','一致する項目がありません。別の言葉で検索してください。'));result.forEach(x=>root.appendChild(libraryCard(x)));}
function renderGuides(){const root=$('guideCards');for(const x of guides){const div=el('div','card');div.append(el('h3','',x.name),el('p','',x.desc),el('span','tag',x.refs.join('・')));root.appendChild(div);}}
let testament='old';function renderBooks(){const books=testament==='old'?oldBooks:newBooks;const root=$('bookGrid');root.replaceChildren();books.forEach((name,i)=>{const b=el('button','book',`${i+1}. ${name}`);b.onclick=()=>{$('bookDetail').replaceChildren(el('h3','',name),el('p','',bookNotes[name]||'聖書を構成する書巻の一つです。本文はお手元の聖書でご確認ください。'),el('span','tag',testament==='old'?'旧約聖書':'新約聖書'));};root.appendChild(b);});$('oldButton').classList.toggle('active',testament==='old');$('newButton').classList.toggle('active',testament==='new');}
function renderPrayers(){const root=$('prayerHistory');root.replaceChildren();if(!state.prayers.length){root.appendChild(el('p','muted','まだ記録がありません。祈りの文を書いて保存できます。'));return;}[...state.prayers].reverse().forEach(x=>{const box=el('div','reading');box.append(el('div','small',new Date(x.at||Date.now()).toLocaleString('ja-JP')),el('p','',x.text));const b=el('button','secondary','削除');b.onclick=()=>{if(confirm('この祈りの記録を削除しますか?')){state.prayers=state.prayers.filter(p=>p.id!==x.id);persist();renderPrayers();stat();}};box.appendChild(b);root.appendChild(box);});}
function generatePrayer(){$('prayerText').value=prayers[$('prayerTopic').value]||'';}
let qi=0,score=0,answered=false;function renderQuiz(){const root=$('quizOptions');root.replaceChildren();$('quizFeedback').classList.add('hide');$('quizNext').classList.add('hide');answered=false;if(qi>=quiz.length){$('quizCount').textContent='終了';$('quizQuestion').textContent=`10問中 ${score}問 正解しました`;if(score>state.best){state.best=score;persist();stat();}$('quizScore').textContent='正解 '+score+'問';$('quizProgress').style.width='100%';root.appendChild(el('p','muted','もう一度挑戦するときは「はじめから」を押してください。'));return;}const q=quiz[qi];$('quizCount').textContent=`第${qi+1}問 / ${quiz.length}問`;$('quizScore').textContent='正解 '+score+'問';$('quizProgress').style.width=`${qi/quiz.length*100}%`;$('quizQuestion').textContent=q.q;q.a.forEach((option,index)=>{const b=el('button','quiz-option',`${index+1}. ${option}`);b.onclick=()=>{if(answered)return;answered=true;const ok=index===q.right;if(ok)score++;[...root.children].forEach((node,i)=>{node.disabled=true;if(i===q.right)node.classList.add('correct');else if(i===index)node.classList.add('wrong');});$('quizScore').textContent='正解 '+score+'問';$('quizFeedback').textContent=(ok?'正解です。':'今回は違いました。')+' '+q.ex;$('quizFeedback').classList.remove('hide');$('quizNext').classList.remove('hide');$('quizNext').textContent=qi===quiz.length-1?'結果を見る →':'次の問題 →';};root.appendChild(b);});}
function download(){const blob=new Blob([JSON.stringify({format:'hikari-plus-v2',exportedAt:new Date().toISOString(),data:state},null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=el('a');a.href=url;a.download='hikari_backup_'+new Date().toISOString().slice(0,10)+'.json';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('バックアップをダウンロードしました');}
function importJson(file){if(!file)return;if(file.size>2*1024*1024){toast('2MB以下のバックアップを選択してください');return;}file.text().then(str=>{const parsed=JSON.parse(str);if(parsed.format!=='hikari-plus-v2'||!parsed.data||typeof parsed.data!=='object')throw Error('形式が違います');const data=parsed.data;if(!Array.isArray(data.chat)||!Array.isArray(data.saved)||!Array.isArray(data.prayers))throw Error('データ形式が不正です');if(!confirm('現在のデータを読み込んだバックアップで置き換えますか?'))return;state=defaults();state.chat=data.chat.filter(x=>x&&['user','bot'].includes(x.role)&&typeof x.text==='string').slice(-120).map(x=>({role:x.role,text:x.text.slice(0,3000),ref:String(x.ref||'').slice(0,200),at:Number(x.at)||Date.now()}));state.saved=data.saved.filter(x=>typeof x==='string'&&library.some(y=>y.id===x)).slice(0,100);state.prayers=data.prayers.filter(x=>x&&typeof x.text==='string').slice(-100).map(x=>({id:String(x.id||''),text:x.text.slice(0,3000),at:Number(x.at)||Date.now()}));state.best=clamp(Number(data.best)||0,0,10);state.dark=data.dark===true;persist();refresh();toast('読み込みました');}).catch(e=>toast('読み込み失敗:'+e.message));}
function applyTheme(){document.body.classList.toggle('dark',state.dark);$('themeButton').textContent=state.dark?'☀ 明るくする':'☾ 暗くする';}
function refresh(){applyTheme();renderChat();renderDaily();renderPrayers();renderSaved();renderSearch();stat();}
function init(){safeLoad();renderGuides();renderBooks();if(!state.chat.length)addChat('bot','こんにちは。「ひかりのことば PLUS」です。聖書や祈りについて、一緒に考えていきましょう。右側の「今日の聖書テーマ」やメニューも使えます。');
refresh();renderQuiz();document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>jump(b.dataset.view));document.querySelectorAll('[data-ask]').forEach(b=>b.onclick=()=>{jump('chat');send(b.dataset.ask)});
$('chatForm').onsubmit=e=>{e.preventDefault();send($('chatInput').value);};$('chatInput').oninput=updateCount;$('chatInput').onkeydown=e=>{if(e.key==='Enter'&&e.ctrlKey){e.preventDefault();send(e.target.value);}};
$('clearChat').onclick=()=>{if(!confirm('会話履歴を消去しますか?'))return;state.chat=[];addChat('bot','履歴を消去しました。新しくお話ししましょう。');};
$('saveDaily').onclick=()=>toggleSaved(dailyItem().id);$('askDaily').onclick=()=>{jump('chat');send('今日のことば');};$('studySearch').oninput=renderSearch;
$('oldButton').onclick=()=>{testament='old';renderBooks();};$('newButton').onclick=()=>{testament='new';renderBooks();};
$('prayerGenerate').onclick=generatePrayer;$('prayerSave').onclick=()=>{const text=$('prayerText').value.trim();if(!text)return toast('祈りの文を入力してください');state.prayers.push({id:String(Date.now())+'_'+Math.random().toString(36).slice(2,7),text:text.slice(0,3000),at:Date.now()});state.prayers=state.prayers.slice(-100);persist();renderPrayers();stat();toast('祈りの文を保存しました');};$('prayerCopy').onclick=async()=>{try{await navigator.clipboard.writeText($('prayerText').value);toast('コピーしました');}catch(e){$('prayerText').select();toast('文章を選択しました。手動でコピーしてください');}};
$('quizRestart').onclick=()=>{qi=0;score=0;renderQuiz();};$('quizNext').onclick=()=>{qi++;renderQuiz();};
$('themeButton').onclick=()=>{state.dark=!state.dark;persist();applyTheme();};$('exportButton').onclick=download;$('exportButton2').onclick=download;$('importButton').onclick=()=>$('importFile').click();$('importFile').onchange=e=>{importJson(e.target.files[0]);e.target.value='';};
$('resetAll').onclick=()=>{if(!confirm('会話、祈り、保存項目を含むすべてのアプリデータを削除しますか?'))return;localStorage.removeItem(KEY);state=defaults();state.chat=[{role:'bot',text:'初期化しました。聖書について、気軽に話しかけてください。',ref:'',at:Date.now()}];qi=0;score=0;renderQuiz();refresh();toast('初期化しました');};}
init();
})();
</script>
</body></html>

文字数カウントサイト

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文字数カウント</title>

  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;
      font-family: Arial, "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
      background: #f4f6f8;
      color: #222;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 30px;
    }

    .container {
      width: 100%;
      max-width: 900px;
      background: white;
      padding: 30px;
      border-radius: 16px;
      box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
    }

    h1 {
      margin-top: 0;
      text-align: center;
    }

    textarea {
      width: 100%;
      height: 350px;
      resize: vertical;
      padding: 18px;
      font-size: 16px;
      line-height: 1.7;
      border: 1px solid #ccc;
      border-radius: 10px;
      outline: none;
    }

    textarea:focus {
      border-color: #555;
    }

    .counts {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 12px;
      margin-top: 20px;
    }

    .count-box {
      background: #f7f7f7;
      padding: 18px;
      border-radius: 10px;
      text-align: center;
    }

    .count-box strong {
      display: block;
      font-size: 28px;
      margin-top: 5px;
    }

    .buttons {
      margin-top: 20px;
      display: flex;
      gap: 10px;
      justify-content: center;
      flex-wrap: wrap;
    }

    button {
      border: none;
      padding: 12px 22px;
      border-radius: 8px;
      cursor: pointer;
      font-size: 15px;
      background: #222;
      color: white;
    }

    button:hover {
      opacity: 0.8;
    }
  </style>
</head>

<body>

<div class="container">

  <h1>文字数カウント</h1>

  <textarea
    id="text"
    placeholder="ここに文章を入力してください"
  ></textarea>

  <div class="counts">

    <div class="count-box">
      文字数
      <strong id="characterCount">0</strong>
    </div>

    <div class="count-box">
      空白を除く
      <strong id="noSpaceCount">0</strong>
    </div>

    <div class="count-box">
      行数
      <strong id="lineCount">0</strong>
    </div>

  </div>

  <div class="buttons">

    <button id="copyButton">
      コピー
    </button>

    <button id="clearButton">
      すべて削除
    </button>

  </div>

</div>

<script>

const textArea = document.getElementById("text");

const characterCount =
document.getElementById("characterCount");

const noSpaceCount =
document.getElementById("noSpaceCount");

const lineCount =
document.getElementById("lineCount");

const copyButton =
document.getElementById("copyButton");

const clearButton =
document.getElementById("clearButton");


function countText() {

  const text = textArea.value;

  // 全文字数
  characterCount.textContent = text.length;

  // 空白・改行を除いた文字数
  noSpaceCount.textContent =
    text.replace(/\s/g, "").length;

  // 行数
  if (text.length === 0) {

    lineCount.textContent = 0;

  } else {

    lineCount.textContent =
      text.split("\n").length;

  }

}


textArea.addEventListener(
  "input",
  countText
);


copyButton.addEventListener(
  "click",
  async () => {

    await navigator.clipboard.writeText(
      textArea.value
    );

    copyButton.textContent =
      "コピーしました";

    setTimeout(() => {

      copyButton.textContent =
        "コピー";

    }, 1500);

  }
);


clearButton.addEventListener(
  "click",
  () => {

    textArea.value = "";

    countText();

    textArea.focus();

  }
);


</script>

</body>
</html>

star_breaker_stg_stage5_power_barrage.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>STAR BREAKER - 5 STAGE STG</title>
<style>
:root{--cyan:#55efff;--pink:#ff4fd8;--yellow:#ffe66d;--bg:#02040e}
*{box-sizing:border-box}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#02040e;color:#fff;font-family:system-ui,-apple-system,"Yu Gothic UI",sans-serif}
body{display:grid;place-items:center;background:radial-gradient(circle at 50% 20%,#17265e,#050817 50%,#010208)}
#wrap{position:relative;width:min(100vw,900px);height:min(100vh,960px);aspect-ratio:3/4;overflow:hidden;border:1px solid #55efff55;box-shadow:0 0 36px #55efff22;background:#02040e}
canvas{display:block;width:100%;height:100%;touch-action:none;cursor:crosshair}
#hud{position:absolute;inset:0;pointer-events:none;font-weight:900;text-shadow:0 0 8px #55efff}
.top{position:absolute;top:12px;left:16px;right:16px;display:flex;justify-content:space-between;gap:12px;font-size:clamp(12px,2vw,19px)}
#stageLabel{position:absolute;top:45px;left:0;right:0;text-align:center;color:#bff8ff;letter-spacing:.22em}
#bossWrap{display:none;position:absolute;top:73px;left:9%;width:82%}
#bossName{text-align:center;font-size:13px;margin-bottom:5px;color:#ffd4f5}
.bar{height:13px;border:1px solid #ffffffaa;background:#ffffff12}
#bossHp{height:100%;width:100%;background:linear-gradient(90deg,#ff4fd8,#ff9a3d)}
.overlay{position:absolute;inset:0;display:grid;place-items:center;background:linear-gradient(#02040e33,#02040ecc);z-index:5}
.panel{width:min(88%,650px);padding:clamp(22px,5vw,44px);text-align:center;background:#081027e8;border:1px solid #55efff88;box-shadow:0 0 28px #55efff33;backdrop-filter:blur(7px)}
h1{margin:0 0 10px;font-size:clamp(38px,8vw,76px);line-height:.9;color:#fff;text-shadow:0 0 9px #fff,0 0 22px #55efff,4px 4px 0 #ff4fd8}
.sub{color:#8deeff;letter-spacing:.25em;margin-bottom:22px;font-weight:800}
#message{line-height:1.8;color:#ddebff}
button{margin:20px 7px 0;padding:14px 28px;border:0;border-radius:999px;color:#fff;font-weight:900;font-size:17px;cursor:pointer;background:linear-gradient(90deg,#55efff,#7277ff,#ff4fd8);box-shadow:0 0 18px #55efff66}
button.secondary{background:#252c46;box-shadow:none;border:1px solid #ffffff44}
button:hover{filter:brightness(1.15);transform:scale(1.03)}
.small{margin-top:18px;padding-top:14px;border-top:1px solid #ffffff22;color:#b7c9dd;font-size:13px;line-height:1.7}
#mobile{position:absolute;left:0;right:0;bottom:12px;display:none;justify-content:space-between;padding:0 14px;pointer-events:none;z-index:4}
.group{display:flex;gap:8px;pointer-events:auto}.touch{width:58px;height:58px;margin:0;padding:0;border-radius:50%;opacity:.62;font-size:20px}
@media(pointer:coarse){#mobile{display:flex}}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game" width="720" height="960"></canvas>

<div id="hud">
  <div class="top">
    <div>SCORE <span id="score">0000000</span></div>
    <div>HI <span id="high">0000000</span></div>
    <div>POWER <span id="power">1</span></div>
  </div>
  <div id="stageLabel">STAGE 1 / 5</div>
  <div id="bossWrap">
    <div id="bossName">BOSS</div>
    <div class="bar"><div id="bossHp"></div></div>
  </div>
</div>

<div id="overlay" class="overlay">
  <div class="panel">
    <h1 id="title">STAR<br>BREAKER</h1>
    <div class="sub" id="subtitle">5 STAGE MISSION</div>
    <div id="message">全5ステージを突破し、最終要塞を破壊せよ。<br>敵弾に一発でも当たると爆発してゲームオーバー。<br>POWERアイテムでショットを最大5段階まで強化。</div>
    <div id="buttons">
      <button id="startBtn">GAME START</button>
    </div>
    <div class="small">PC:WASD / 矢印キー / マウスドラッグで移動 | P:一時停止<br>スマホ:画面ドラッグ、または下の移動ボタン</div>
  </div>
</div>

<div id="mobile">
  <div class="group"><button class="touch" data-key="left">◀</button><button class="touch" data-key="right">▶</button></div>
  <div class="group"><button class="touch" data-key="up">▲</button><button class="touch" data-key="down">▼</button></div>
</div>
</div>

<script>
(()=>{
"use strict";
const canvas=document.getElementById("game"),ctx=canvas.getContext("2d");
const W=720,H=960;
const scoreEl=document.getElementById("score"),highEl=document.getElementById("high"),powerEl=document.getElementById("power");
const stageLabel=document.getElementById("stageLabel"),bossWrap=document.getElementById("bossWrap"),bossName=document.getElementById("bossName"),bossHp=document.getElementById("bossHp");
const overlay=document.getElementById("overlay"),title=document.getElementById("title"),subtitle=document.getElementById("subtitle"),message=document.getElementById("message"),buttons=document.getElementById("buttons");

let running=false,paused=false,dead=false,last=0,time=0,score=0,high=Number(localStorage.getItem("starBreaker5High")||0);
let stage=1,stageTime=0,waveTimer=0,wavesSpawned=0,bossActive=false,transition=false,shake=0,flash=0;
let pointer=false;
const keys={},bullets=[],enemyBullets=[],enemies=[],particles=[],powerups=[],stars=[];

const stageData=[
 {name:"ORBITAL FRONT",duration:23,boss:"IRON TALON",hp:260,bg:["#07112c","#02040e"]},
 {name:"CRIMSON NEBULA",duration:25,boss:"SCARLET MANTA",hp:380,bg:["#2b071c","#07030b"]},
 {name:"ASTEROID BELT",duration:27,boss:"GRAVITY GOLEM",hp:520,bg:["#241b10","#050403"]},
 {name:"VOID TEMPLE",duration:29,boss:"ABYSS SERAPH",hp:700,bg:["#16072a","#030109"]},
 {name:"FINAL FORTRESS",duration:32,boss:"OMEGA EMPRESS",hp:980,bg:["#051b26","#010408"]}
];

const player={x:W/2,y:H-120,r:11,speed:410,cool:0,rate:.12,power:1,maxPower:5,inv:0,tilt:0};
for(let i=0;i<150;i++)stars.push({x:Math.random()*W,y:Math.random()*H,z:Math.random()*2.5+.5,a:Math.random()*.8+.2});

function clearAll(){bullets.length=enemyBullets.length=enemies.length=particles.length=powerups.length=0}
function updateHUD(){
 scoreEl.textContent=String(Math.floor(score)).padStart(7,"0");
 highEl.textContent=String(Math.floor(high)).padStart(7,"0");
 powerEl.textContent=player.power>=player.maxPower?"MAX":player.power;
 stageLabel.textContent=`STAGE ${stage} / 5 ${stageData[stage-1].name}`;
}
function reset(){
 clearAll();stage=1;stageTime=0;waveTimer=0;wavesSpawned=0;bossActive=false;transition=false;dead=false;paused=false;
 score=0;time=0;shake=0;flash=0;player.x=W/2;player.y=H-120;player.cool=0;player.power=1;player.inv=1;
 bossWrap.style.display="none";updateHUD();
}
function begin(){
 reset();running=true;overlay.style.display="none";last=performance.now();requestAnimationFrame(loop);
}
function addScore(n){
 score+=n;if(score>high){high=score;localStorage.setItem("starBreaker5High",String(Math.floor(high)))}updateHUD();
}
function burst(x,y,color,count=25,speed=260){
 for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=Math.random()*speed+40;
 particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:Math.random()*.7+.3,max:1,size:Math.random()*6+2,color});
 }}
function shoot(){
 let shots=[];
 if(player.power===1)shots=[{a:0,x:0,d:1}];
 if(player.power===2)shots=[{a:-.10,x:-7,d:1},{a:.10,x:7,d:1}];
 if(player.power===3)shots=[{a:-.18,x:-10,d:1},{a:0,x:0,d:2},{a:.18,x:10,d:1}];
 if(player.power===4)shots=[{a:-.25,x:-15,d:2},{a:-.08,x:-5,d:2},{a:.08,x:5,d:2},{a:.25,x:15,d:2}];
 if(player.power>=5)shots=[
   {a:-.31,x:-18,d:2},{a:-.16,x:-10,d:2},{a:0,x:0,d:3},
   {a:.16,x:10,d:2},{a:.31,x:18,d:2}
 ];
 shots.forEach(s=>bullets.push({
   x:player.x+s.x,y:player.y-20,
   vx:Math.sin(s.a)*230,vy:-820,
   r:s.d>=3?7:5,dmg:s.d,power:player.power
 }));
 player.rate=Math.max(.065,.13-player.power*.013);
}
function spawnEnemy(type,x,y){
 const s=stage;
 if(type==="scout")enemies.push({type,x,y,r:19,hp:1+s,maxHp:1+s,vx:(Math.random()-.5)*130,vy:105+s*11,age:0,shot:1.2+Math.random()});
 if(type==="zig")enemies.push({type,x,y,r:22,hp:3+s,maxHp:3+s,vx:0,vy:80+s*7,age:Math.random()*5,shot:.9+Math.random()});
 if(type==="tank")enemies.push({type,x,y,r:32,hp:11+s*4,maxHp:11+s*4,vx:0,vy:42+s*3,age:0,shot:.7});
}
function spawnWave(){
 wavesSpawned++;
 const pattern=(wavesSpawned+stage)%4;
 if(pattern===0){for(let i=0;i<7;i++)spawnEnemy("scout",75+i*95,-40-i*34)}
 if(pattern===1){for(let i=0;i<6;i++)spawnEnemy("zig",90+i*108,-45-i*70)}
 if(pattern===2){spawnEnemy("tank",W/2,-70);for(let i=0;i<4;i++)spawnEnemy("scout",120+i*160,-160-i*40)}
 if(pattern===3){for(let i=0;i<3;i++){spawnEnemy("zig",170+i*190,-60-i*80);spawnEnemy("scout",90+i*250,-190-i*50)}}
}
function spawnBoss(){
 bossActive=true;enemyBullets.length=0;
 const d=stageData[stage-1];
 enemies.push({type:"boss",x:W/2,y:-130,r:82,hp:d.hp,maxHp:d.hp,age:0,shot:.6,stage,vy:48});
 bossName.textContent=d.boss;bossHp.style.width="100%";bossWrap.style.display="block";
}
function addEnemyBullet(x,y,a,speed,r=7,color="#ff8ae7"){
 enemyBullets.push({x,y,vx:Math.cos(a)*speed,vy:Math.sin(a)*speed,r,color});
}
function enemyShoot(e){
 const aim=Math.atan2(player.y-e.y,player.x-e.x);
 const base=145+stage*18;

 if(e.type==="scout"){
   if(stage<=2){
     for(let i=-1;i<=1;i++)addEnemyBullet(e.x,e.y,aim+i*.12,base,6,"#ff8ae7");
   }else{
     for(let i=-2;i<=2;i++)addEnemyBullet(e.x,e.y,aim+i*.13,base+12,6,"#ff8ae7");
   }
 }

 if(e.type==="zig"){
   const count=5+stage;
   for(let i=0;i<count;i++){
     const a=(i/(count-1)-.5)*1.15+Math.PI/2;
     addEnemyBullet(e.x,e.y,a,145+stage*12,6,"#9d8cff");
   }
   addEnemyBullet(e.x,e.y,aim,210+stage*8,5,"#ffffff");
 }

 if(e.type==="tank"){
   const n=10+stage*2;
   for(let i=0;i<n;i++){
     const a=i*Math.PI*2/n+e.age*.32;
     addEnemyBullet(e.x,e.y,a,125+stage*10,7,"#ffb25f");
   }
   for(let i=-2;i<=2;i++)addEnemyBullet(e.x,e.y,aim+i*.15,190+stage*10,6,"#fff0a8");
 }

 if(e.type==="boss"){
   const ratio=e.hp/e.maxHp;

   if(e.stage===1){
     for(let ring=0;ring<2;ring++){
       for(let i=-5;i<=5;i++){
         const a=aim+i*.12+(ring?0.055:0);
         addEnemyBullet(e.x,e.y+35,a,195+ring*45,7,ring?"#ffd1f4":"#ff78d8");
       }
     }
   }

   if(e.stage===2){
     const n=22;
     for(let i=0;i<n;i++){
       const a=i*Math.PI*2/n+e.age*.62;
       addEnemyBullet(e.x,e.y,a,155+(i%2)*42,7,i%2?"#ff7ac8":"#a884ff");
     }
     for(let i=-2;i<=2;i++)addEnemyBullet(e.x,e.y+25,aim+i*.13,235,6,"#ffffff");
   }

   if(e.stage===3){
     for(let ring=0;ring<3;ring++){
       const n=12+ring*4;
       for(let i=0;i<n;i++){
         const a=i*Math.PI*2/n+e.age*(ring%2?-.42:.42);
         addEnemyBullet(e.x,e.y,a,115+ring*42,8-ring,"#ffb35f");
       }
     }
   }

   if(e.stage===4){
     for(let side=-1;side<=1;side+=2){
       for(let i=0;i<9;i++){
         const a=aim+side*(.08+i*.085)+Math.sin(e.age*1.8)*.18;
         addEnemyBullet(e.x+side*45,e.y+20,a,205+i*6,6,"#b477ff");
       }
     }
     if(ratio<.5){
       for(let i=0;i<18;i++){
         const a=i*Math.PI/9-e.age*.55;
         addEnemyBullet(e.x,e.y,a,175,7,"#ff70dd");
       }
     }
   }

   if(e.stage===5){
     const n=ratio>.6?28:38;
     for(let i=0;i<n;i++){
       const a=i*Math.PI*2/n+e.age*.78;
       addEnemyBullet(e.x,e.y,a,150+(i%4)*28,7,i%2?"#ff4fd8":"#65eaff");
     }
     for(let i=-5;i<=5;i++)addEnemyBullet(e.x,e.y+42,aim+i*.095,250,6,"#fff2a8");
     if(ratio<.45){
       for(let i=0;i<20;i++){
         const a=i*Math.PI/10-e.age*1.05;
         addEnemyBullet(e.x,e.y,a,225,5,"#ffffff");
       }
     }
   }
 }
}
function collide(a,b){const dx=a.x-b.x,dy=a.y-b.y;return dx*dx+dy*dy<(a.r+b.r)*(a.r+b.r)}
function gameOver(){
 if(dead)return;dead=true;running=false;shake=34;flash=.75;
 burst(player.x,player.y,"#ff4fd8",120,430);burst(player.x,player.y,"#ffe66d",80,360);
 draw();
 setTimeout(()=>{
  title.innerHTML="GAME<br>OVER";subtitle.textContent="SHIP DESTROYED";
  message.innerHTML=`敵弾に命中し、機体が爆発した。<br>SCORE:<b>${Math.floor(score).toLocaleString()}</b> STAGE:<b>${stage}</b>`;
  buttons.innerHTML='<button id="retryBtn">リトライ</button><button id="quitBtn" class="secondary">やめる</button>';
  overlay.style.display="grid";
  document.getElementById("retryBtn").addEventListener("click",begin);
  document.getElementById("quitBtn").addEventListener("click",quitGame);
 },650);
}
function quitGame(){
 clearAll();dead=false;bossWrap.style.display="none";
 title.innerHTML="STAR<br>BREAKER";subtitle.textContent="MISSION ABORTED";
 message.innerHTML="ゲームを終了しました。<br>もう一度遊ぶときはスタートを押してください。";
 buttons.innerHTML='<button id="againBtn">GAME START</button>';
 document.getElementById("againBtn").addEventListener("click",begin);
 draw();
}
function stageClear(){
 transition=true;bossActive=false;bossWrap.style.display="none";enemyBullets.length=0;clearAll();
 addScore(2500*stage);
 if(stage===5){running=false;title.innerHTML="ALL<br>CLEAR";subtitle.textContent="OMEGA FORTRESS DESTROYED";
  message.innerHTML=`全5ステージ制覇!<br>FINAL SCORE:<b>${Math.floor(score).toLocaleString()}</b>`;
  buttons.innerHTML='<button id="retryAll">もう一度遊ぶ</button><button id="quitAll" class="secondary">やめる</button>';
  overlay.style.display="grid";
  document.getElementById("retryAll").addEventListener("click",begin);
  document.getElementById("quitAll").addEventListener("click",quitGame);
  return;
 }
 running=false;title.innerHTML=`STAGE ${stage}<br>CLEAR`;subtitle.textContent=stageData[stage-1].boss+" DESTROYED";
 message.innerHTML=`次は STAGE ${stage+1}「${stageData[stage].name}」`;
 buttons.innerHTML='<button id="nextStage">NEXT STAGE</button>';
 overlay.style.display="grid";
 document.getElementById("nextStage").addEventListener("click",()=>{
   stage++;stageTime=0;waveTimer=0;wavesSpawned=0;transition=false;player.x=W/2;player.y=H-120;player.inv=1.5;
   updateHUD();overlay.style.display="none";running=true;last=performance.now();requestAnimationFrame(loop);
 });
}
function update(dt){
 time+=dt;stageTime+=dt;if(player.inv>0)player.inv-=dt;if(shake>0)shake=Math.max(0,shake-dt*55);if(flash>0)flash=Math.max(0,flash-dt*1.7);
 stars.forEach(s=>{s.y+=(55+s.z*65+stage*8)*dt;if(s.y>H){s.y=-5;s.x=Math.random()*W}});
 let dx=0,dy=0;if(keys.ArrowLeft||keys.a||keys.left)dx--;if(keys.ArrowRight||keys.d||keys.right)dx++;if(keys.ArrowUp||keys.w||keys.up)dy--;if(keys.ArrowDown||keys.s||keys.down)dy++;
 const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;player.x+=dx*player.speed*dt;player.y+=dy*player.speed*dt;player.x=Math.max(25,Math.min(W-25,player.x));player.y=Math.max(95,Math.min(H-35,player.y));player.tilt+=(dx*.45-player.tilt)*Math.min(1,dt*10);
 player.cool-=dt;if(player.cool<=0){shoot();player.cool=player.rate}
 if(!bossActive){
   waveTimer-=dt;if(waveTimer<=0){spawnWave();waveTimer=Math.max(2.5,4.6-stage*.28)}
   if(stageTime>=stageData[stage-1].duration)spawnBoss();
 }
 for(let i=bullets.length-1;i>=0;i--){const b=bullets[i];b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.y<-30||b.x<-30||b.x>W+30)bullets.splice(i,1)}
 for(let i=enemyBullets.length-1;i>=0;i--){const b=enemyBullets[i];b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.y>H+50||b.y<-60||b.x<-60||b.x>W+60){enemyBullets.splice(i,1);continue}if(player.inv<=0&&collide(b,player)){enemyBullets.splice(i,1);gameOver();return}}
 for(let i=enemies.length-1;i>=0;i--){const e=enemies[i];e.age+=dt;
   if(e.type==="scout"){e.x+=e.vx*dt;e.y+=e.vy*dt;if(e.x<30||e.x>W-30)e.vx*=-1}
   if(e.type==="zig"){e.y+=e.vy*dt;e.x+=Math.sin(e.age*2.5)*125*dt}
   if(e.type==="tank"){e.y+=e.vy*dt;e.x=W/2+Math.sin(e.age*.85)*220}
   if(e.type==="boss"){if(e.y<165)e.y+=e.vy*dt;else e.x=W/2+Math.sin(e.age*(.55+e.stage*.06))*245;bossHp.style.width=Math.max(0,e.hp/e.maxHp*100)+"%"}
   e.shot-=dt;if(e.shot<=0&&e.y>0){enemyShoot(e);e.shot=e.type==="boss"?Math.max(.22,.62-stage*.065):e.type==="tank"?1.08:e.type==="zig"?.92:1.12}
   for(let j=bullets.length-1;j>=0;j--){const b=bullets[j];if(collide(b,e)){e.hp-=b.dmg;bullets.splice(j,1);burst(b.x,b.y,"#ffe66d",4,80);
     if(e.hp<=0){const boss=e.type==="boss";burst(e.x,e.y,boss?"#ff4fd8":"#55efff",boss?130:28,boss?430:230);shake=boss?30:7;
       addScore(boss?5000*stage:e.type==="tank"?550:e.type==="zig"?220:100);
       if(!boss){
         const dropRate=e.type==="tank"?.60:e.type==="zig"?.32:.20;
         if(Math.random()<dropRate)powerups.push({x:e.x,y:e.y,r:15,vy:82,spin:0});
       }
       enemies.splice(i,1);if(boss){enemyBullets.length=0;setTimeout(stageClear,550)}break;
     }}}
   if(i<enemies.length&&e.y>H+100)enemies.splice(i,1);
   else if(i<enemies.length&&player.inv<=0&&collide(e,player)){gameOver();return}
 }
 for(let i=powerups.length-1;i>=0;i--){const p=powerups[i];p.y+=p.vy*dt;p.spin+=dt*4;if(collide(p,player)){player.power=Math.min(player.maxPower,player.power+1);addScore(player.power>=player.maxPower?800:400);burst(p.x,p.y,"#ffe66d",20,190);powerups.splice(i,1);updateHUD()}else if(p.y>H+30)powerups.splice(i,1)}
 for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=.984;p.vy*=.984;p.life-=dt;if(p.life<=0)particles.splice(i,1)}
}
function drawShip(){
 ctx.save();ctx.translate(player.x,player.y);ctx.rotate(player.tilt);if(player.inv>0&&Math.floor(player.inv*13)%2===0)ctx.globalAlpha=.3;
 ctx.shadowBlur=22;ctx.shadowColor="#55efff";ctx.fillStyle="#e9fdff";ctx.beginPath();ctx.moveTo(0,-27);ctx.lineTo(18,19);ctx.lineTo(0,10);ctx.lineTo(-18,19);ctx.closePath();ctx.fill();
 ctx.fillStyle="#ff4fd8";ctx.beginPath();ctx.moveTo(0,-13);ctx.lineTo(7,11);ctx.lineTo(-7,11);ctx.closePath();ctx.fill();
 ctx.shadowColor="#ff9a3d";ctx.fillStyle="#fff";ctx.beginPath();ctx.moveTo(-7,15);ctx.lineTo(0,35+Math.random()*9);ctx.lineTo(7,15);ctx.closePath();ctx.fill();ctx.restore();
}
function drawEnemy(e){
 ctx.save();ctx.translate(e.x,e.y);
 if(e.type==="boss"){ctx.rotate(Math.sin(e.age*.8)*.07);ctx.shadowBlur=28;ctx.shadowColor="#ff4fd8";ctx.fillStyle=`hsl(${305-e.stage*18} 65% 22%)`;ctx.beginPath();ctx.moveTo(0,-72);ctx.lineTo(96,-18);ctx.lineTo(65,60);ctx.lineTo(0,36);ctx.lineTo(-65,60);ctx.lineTo(-96,-18);ctx.closePath();ctx.fill();ctx.strokeStyle="#ff76df";ctx.lineWidth=5;ctx.stroke();ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(0,-4,25,0,Math.PI*2);ctx.fill();ctx.fillStyle="#ff4fd8";ctx.beginPath();ctx.arc(0,-4,11,0,Math.PI*2);ctx.fill()}
 else{const c=e.type==="tank"?"#ff9a3d":e.type==="zig"?"#a873ff":"#55efff";ctx.shadowBlur=16;ctx.shadowColor=c;ctx.fillStyle=c;if(e.type==="tank"){ctx.beginPath();ctx.roundRect(-29,-24,58,48,12);ctx.fill();ctx.fillStyle="#fff";ctx.fillRect(-8,-31,16,24)}else{ctx.rotate(Math.PI);ctx.beginPath();ctx.moveTo(0,-24);ctx.lineTo(22,18);ctx.lineTo(0,8);ctx.lineTo(-22,18);ctx.closePath();ctx.fill();ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(0,2,6,0,Math.PI*2);ctx.fill()}}
 ctx.restore();
}
function draw(){
 const bg=stageData[Math.max(0,stage-1)].bg,g=ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,bg[0]);g.addColorStop(1,bg[1]);ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
 ctx.save();if(shake>0)ctx.translate((Math.random()-.5)*shake,(Math.random()-.5)*shake);
 stars.forEach(s=>{ctx.globalAlpha=s.a;ctx.fillStyle="#fff";ctx.fillRect(s.x,s.y,s.z,s.z*3)});ctx.globalAlpha=1;
 for(const b of bullets){ctx.shadowBlur=15;ctx.shadowColor="#55efff";ctx.fillStyle="#efffff";ctx.fillRect(b.x-3,b.y-13,6,24)}ctx.shadowBlur=0;
 for(const b of enemyBullets){ctx.shadowBlur=14;ctx.shadowColor=b.color||"#ff4fd8";ctx.fillStyle=b.color||"#ff8ae7";ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,Math.PI*2);ctx.fill()}ctx.shadowBlur=0;
 enemies.forEach(drawEnemy);
 powerups.forEach(p=>{ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.spin);ctx.shadowBlur=18;ctx.shadowColor="#ffe66d";ctx.fillStyle="#ffe66d";ctx.beginPath();for(let i=0;i<8;i++){const a=i*Math.PI/4,r=i%2?7:15;ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r)}ctx.closePath();ctx.fill();ctx.restore()});
 particles.forEach(p=>{ctx.globalAlpha=Math.max(0,p.life/p.max);ctx.fillStyle=p.color;ctx.fillRect(p.x,p.y,p.size,p.size)});ctx.globalAlpha=1;
 if(!dead)drawShip();
 if(paused){ctx.fillStyle="#0009";ctx.fillRect(0,0,W,H);ctx.fillStyle="#fff";ctx.textAlign="center";ctx.font="bold 54px system-ui";ctx.fillText("PAUSE",W/2,H/2)}
 if(flash>0){ctx.globalAlpha=flash;ctx.fillStyle="#fff";ctx.fillRect(0,0,W,H);ctx.globalAlpha=1}
 ctx.restore();
}
function loop(now){if(!running)return;const dt=Math.min(.033,(now-last)/1000);last=now;if(!paused)update(dt);draw();if(running)requestAnimationFrame(loop)}
function setPointer(e){const r=canvas.getBoundingClientRect();player.x=(e.clientX-r.left)/r.width*W;player.y=(e.clientY-r.top)/r.height*H}
addEventListener("keydown",e=>{keys[e.key]=true;keys[e.key.toLowerCase()]=true;if(e.key.toLowerCase()==="p"&&running)paused=!paused;if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].includes(e.key))e.preventDefault()});
addEventListener("keyup",e=>{keys[e.key]=false;keys[e.key.toLowerCase()]=false});
canvas.addEventListener("pointerdown",e=>{pointer=true;setPointer(e)});canvas.addEventListener("pointermove",e=>{if(pointer)setPointer(e)});addEventListener("pointerup",()=>pointer=false);
document.querySelectorAll(".touch").forEach(b=>{const k=b.dataset.key;b.addEventListener("pointerdown",e=>{e.preventDefault();keys[k]=true});b.addEventListener("pointerup",()=>keys[k]=false);b.addEventListener("pointercancel",()=>keys[k]=false)});
document.getElementById("startBtn").addEventListener("click",begin);
highEl.textContent=String(high).padStart(7,"0");updateHUD();draw();
})();
</script>
</body>
</html>

クローラー型検索エンジン

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Crawlio Search</title>
    <style>
      :root {
        color-scheme: light;
        --text: #202124;
        --muted: #5f6368;
        --line: #dadce0;
        --blue: #4285f4;
        --red: #ea4335;
        --yellow: #fbbc04;
        --green: #34a853;
        --shadow: 0 18px 40px rgba(60, 64, 67, 0.15);
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        min-height: 100vh;
        color: var(--text);
        font-family: Arial, "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
        background:
          radial-gradient(circle at top left, rgba(66, 133, 244, 0.12), transparent 32rem),
          linear-gradient(180deg, #fff 0%, #f7f9fc 68%, #eef3fa 100%);
      }

      a {
        color: inherit;
        text-decoration: none;
      }

      .topbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        min-height: 64px;
        padding: 0 28px;
      }

      .brand {
        display: inline-flex;
        align-items: center;
        gap: 7px;
        color: #3c4043;
        font-size: 15px;
        font-weight: 700;
      }

      .brand-dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
      }

      .nav {
        display: flex;
        gap: 22px;
        color: #3c4043;
        font-size: 14px;
      }

      .nav a:hover {
        text-decoration: underline;
      }

      main {
        width: min(1120px, calc(100% - 32px));
        margin: 0 auto;
      }

      .search-shell {
        position: relative;
        display: grid;
        place-items: center;
        min-height: 430px;
        padding: 38px 0 46px;
        overflow: hidden;
      }

      .crawler-visual {
        position: absolute;
        inset: 12px 0 auto;
        height: 320px;
        pointer-events: none;
        opacity: 0.92;
      }

      .orbit {
        position: absolute;
        left: 50%;
        top: 50%;
        border: 1px solid rgba(95, 99, 104, 0.18);
        border-radius: 50%;
        transform: translate(-50%, -50%);
      }

      .orbit-a {
        width: min(640px, 86vw);
        height: 210px;
      }

      .orbit-b {
        width: min(440px, 68vw);
        height: 145px;
        transform: translate(-50%, -50%) rotate(-12deg);
      }

      .node {
        position: absolute;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        box-shadow: 0 0 0 8px rgba(66, 133, 244, 0.08);
      }

      .node-a {
        left: calc(50% - 302px);
        top: 120px;
        background: var(--blue);
      }

      .node-b {
        left: calc(50% + 250px);
        top: 88px;
        background: var(--green);
      }

      .node-c {
        left: calc(50% + 72px);
        top: 216px;
        background: var(--red);
      }

      .scan-line {
        position: absolute;
        left: 50%;
        top: 64px;
        width: 3px;
        height: 220px;
        background: linear-gradient(180deg, transparent, rgba(66, 133, 244, 0.72), transparent);
        animation: scan 3.4s ease-in-out infinite;
      }

      .wordmark {
        position: relative;
        z-index: 1;
        margin: 52px 0 25px;
        font-size: clamp(68px, 12vw, 112px);
        font-weight: 700;
        line-height: 0.95;
      }

      .blue { color: var(--blue); }
      .red { color: var(--red); }
      .yellow { color: var(--yellow); }
      .green { color: var(--green); }

      .search-form {
        position: relative;
        z-index: 1;
        width: min(640px, 100%);
      }

      .search-box {
        display: grid;
        grid-template-columns: 24px 1fr 42px;
        align-items: center;
        min-height: 58px;
        padding: 0 8px 0 21px;
        background: #fff;
        border: 1px solid var(--line);
        border-radius: 32px;
        box-shadow: 0 2px 8px rgba(60, 64, 67, 0.08);
        transition: box-shadow 160ms ease, border-color 160ms ease;
      }

      .search-box:focus-within,
      .search-box:hover {
        border-color: transparent;
        box-shadow: var(--shadow);
      }

      .search-box svg,
      .icon-button svg {
        width: 22px;
        height: 22px;
        fill: #5f6368;
      }

      input {
        width: 100%;
        border: 0;
        outline: 0;
        padding: 0 14px;
        color: var(--text);
        font-size: 17px;
        background: transparent;
      }

      .icon-button {
        display: grid;
        place-items: center;
        width: 42px;
        height: 42px;
        border: 0;
        border-radius: 50%;
        background: transparent;
        cursor: pointer;
      }

      .icon-button:hover {
        background: #f1f3f4;
      }

      .actions {
        display: flex;
        justify-content: center;
        gap: 12px;
        margin-top: 24px;
      }

      .actions button {
        min-width: 112px;
        min-height: 38px;
        border: 1px solid #f8f9fa;
        border-radius: 4px;
        padding: 0 18px;
        color: #3c4043;
        background: #f8f9fa;
        font-size: 14px;
        cursor: pointer;
      }

      .actions button:hover {
        border-color: #dadce0;
        box-shadow: 0 1px 1px rgba(0, 0, 0, 0.08);
      }

      .results-area {
        display: none;
        max-width: 760px;
        margin: 0 auto 46px;
      }

      .results-area.visible {
        display: block;
      }

      .result-meta {
        margin-bottom: 18px;
        color: var(--muted);
        font-size: 14px;
      }

      .result {
        padding: 18px 0;
        border-top: 1px solid #edf0f2;
      }

      .result-url {
        color: #3c4043;
        font-size: 13px;
      }

      .result h3 {
        margin: 4px 0 6px;
        color: #1a0dab;
        font-size: 21px;
        font-weight: 400;
      }

      .result p {
        margin: 0;
        color: #4d5156;
        font-size: 14px;
        line-height: 1.55;
      }

      .crawler-panel {
        margin: 12px 0 28px;
        padding: 24px;
        background: rgba(255, 255, 255, 0.82);
        border: 1px solid rgba(218, 220, 224, 0.9);
        border-radius: 8px;
        box-shadow: 0 12px 32px rgba(60, 64, 67, 0.08);
        backdrop-filter: blur(10px);
      }

      .panel-heading {
        display: flex;
        align-items: end;
        justify-content: space-between;
        gap: 18px;
        margin-bottom: 18px;
      }

      .eyebrow {
        margin: 0 0 4px;
        color: var(--blue);
        font-size: 12px;
        font-weight: 700;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }

      h2 {
        margin: 0;
        font-size: 24px;
      }

      .pulse {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        color: #137333;
        font-size: 12px;
        font-weight: 700;
      }

      .pulse::before {
        content: "";
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--green);
        box-shadow: 0 0 0 8px rgba(52, 168, 83, 0.12);
      }

      .crawl-grid {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 12px;
      }

      .crawl-card {
        min-height: 122px;
        padding: 16px;
        border: 1px solid #e6eaee;
        border-radius: 8px;
        background: #fff;
      }

      .crawl-card strong {
        display: block;
        margin-bottom: 8px;
        font-size: 15px;
      }

      .crawl-card span {
        display: block;
        color: var(--muted);
        font-size: 13px;
        line-height: 1.45;
      }

      .crawl-progress {
        height: 5px;
        margin-top: 14px;
        overflow: hidden;
        border-radius: 999px;
        background: #edf0f2;
      }

      .crawl-progress i {
        display: block;
        height: 100%;
        width: var(--progress);
        border-radius: inherit;
        background: linear-gradient(90deg, var(--blue), var(--green));
      }

      .stats-band {
        display: grid;
        grid-template-columns: repeat(4, minmax(0, 1fr));
        gap: 1px;
        overflow: hidden;
        margin-bottom: 44px;
        border: 1px solid #dfe4ea;
        border-radius: 8px;
        background: #dfe4ea;
      }

      .stats-band div {
        padding: 22px;
        background: #fff;
      }

      .stats-band strong,
      .stats-band span {
        display: block;
      }

      .stats-band strong {
        margin-bottom: 5px;
        font-size: 27px;
      }

      .stats-band span {
        color: var(--muted);
        font-size: 13px;
      }

      footer {
        display: flex;
        flex-wrap: wrap;
        gap: 22px;
        padding: 18px 28px;
        color: #70757a;
        background: #f2f2f2;
        font-size: 14px;
      }

      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }

      @keyframes scan {
        0%, 100% {
          transform: translateX(-260px);
          opacity: 0.35;
        }
        50% {
          transform: translateX(260px);
          opacity: 1;
        }
      }

      @media (max-width: 760px) {
        .topbar {
          padding: 0 16px;
        }

        .nav {
          gap: 12px;
          font-size: 13px;
        }

        .search-shell {
          min-height: 390px;
        }

        .crawler-visual {
          height: 270px;
        }

        .node-a {
          left: 6%;
        }

        .node-b {
          left: 86%;
        }

        .node-c {
          left: 58%;
        }

        .actions {
          flex-wrap: wrap;
        }

        .crawl-grid,
        .stats-band {
          grid-template-columns: 1fr;
        }

        .panel-heading {
          align-items: start;
          flex-direction: column;
        }
      }
    </style>
  </head>
  <body>
    <header class="topbar">
      <a class="brand" href="#" aria-label="Crawlio Search">
        <span class="brand-dot blue"></span>
        <span class="brand-dot red"></span>
        <span class="brand-dot yellow"></span>
        <span class="brand-dot green"></span>
        <span>Crawlio</span>
      </a>
      <nav class="nav" aria-label="メイン">
        <a href="#crawler">Crawler</a>
        <a href="#index">Index</a>
        <a href="#status">Status</a>
      </nav>
    </header>

    <main>
      <section class="search-shell" aria-labelledby="hero-title">
        <div class="crawler-visual" aria-hidden="true">
          <div class="orbit orbit-a"></div>
          <div class="orbit orbit-b"></div>
          <div class="node node-a"></div>
          <div class="node node-b"></div>
          <div class="node node-c"></div>
          <div class="scan-line"></div>
        </div>
        <h1 id="hero-title" class="wordmark">
          <span class="blue">C</span><span class="red">r</span><span class="yellow">a</span><span class="blue">w</span><span class="green">l</span><span class="red">i</span><span class="blue">o</span>
        </h1>
        <form class="search-form" id="searchForm">
          <label class="sr-only" for="query">検索キーワード</label>
          <div class="search-box">
            <svg aria-hidden="true" viewBox="0 0 24 24">
              <path d="M10.8 18a7.2 7.2 0 1 1 5.1-12.3 7.2 7.2 0 0 1-5.1 12.3Zm0-2a5.2 5.2 0 1 0 0-10.4 5.2 5.2 0 0 0 0 10.4Zm6.3.1 4 4-1.4 1.4-4-4 1.4-1.4Z" />
            </svg>
            <input id="query" name="query" autocomplete="off" placeholder="URL、キーワード、サイト名を検索" />
            <button class="icon-button" type="button" id="voiceButton" aria-label="音声検索">
              <svg aria-hidden="true" viewBox="0 0 24 24">
                <path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1a7 7 0 0 0 6-6.9h-2Z" />
              </svg>
            </button>
          </div>
          <div class="actions">
            <button type="submit">検索</button>
            <button type="button" id="crawlButton">クローラーを走らせる</button>
          </div>
        </form>
      </section>

      <section class="results-area" aria-live="polite">
        <div class="result-meta" id="resultMeta">約 8,420,000 件中 0.38 秒</div>
        <div class="results" id="results"></div>
      </section>

      <section class="crawler-panel" id="crawler" aria-labelledby="crawler-title">
        <div class="panel-heading">
          <div>
            <p class="eyebrow">Live Crawl</p>
            <h2 id="crawler-title">巡回中のページ</h2>
          </div>
          <span class="pulse">ONLINE</span>
        </div>
        <div class="crawl-grid" id="crawlGrid"></div>
      </section>

      <section class="stats-band" id="index" aria-label="インデックス統計">
        <div>
          <strong>12.8B</strong>
          <span>Indexed pages</span>
        </div>
        <div>
          <strong>94ms</strong>
          <span>Median lookup</span>
        </div>
        <div>
          <strong>37K/s</strong>
          <span>Crawl rate</span>
        </div>
        <div>
          <strong>99.98%</strong>
          <span>Freshness</span>
        </div>
      </section>
    </main>

    <footer id="status">
      <span>Japan</span>
      <span>Privacy</span>
      <span>Terms</span>
      <span>Search Console</span>
    </footer>

    <script>
      const results = [
        {
          title: "Crawlio Search Console - サイトのクロール状況",
          url: "https://crawlio.example/search-console",
          text: "サイトマップ、robots.txt、インデックス登録、検索パフォーマンスをまとめて確認できます。"
        },
        {
          title: "高速インデックスの仕組み",
          url: "https://crawlio.example/docs/indexing",
          text: "分散クローラーがページを発見し、内容を解析して、新しい検索結果へ反映します。"
        },
        {
          title: "ニュース、画像、動画を横断検索",
          url: "https://crawlio.example/discover",
          text: "キーワードに関連するページ、メディア、トレンドをひとつの検索画面で素早く探せます。"
        },
        {
          title: "Web Crawler Health Report",
          url: "https://status.crawlio.example/crawler",
          text: "現在のクロール速度、エラー率、再訪問キュー、インデックス鮮度のライブ統計です。"
        }
      ];

      const crawlItems = [
        ["news.metro.jp/today", "HTML parsed / 32 links discovered", 78],
        ["shop.example.com/products", "Sitemap queued / canonical found", 64],
        ["docs.dev.local/api", "Robots allowed / snippets updated", 91],
        ["media.example.net/video", "Metadata extracted / thumbnail indexed", 56],
        ["blog.studio.jp/launch", "Fresh content detected / rank signals ready", 84],
        ["archive.city.jp/events", "Recrawl scheduled / duplicate checked", 43]
      ];

      const form = document.querySelector("#searchForm");
      const queryInput = document.querySelector("#query");
      const resultsArea = document.querySelector(".results-area");
      const resultMeta = document.querySelector("#resultMeta");
      const resultList = document.querySelector("#results");
      const crawlGrid = document.querySelector("#crawlGrid");
      const crawlButton = document.querySelector("#crawlButton");
      const voiceButton = document.querySelector("#voiceButton");

      function renderResults(query = "クローラー") {
        const filtered = results.map((item) => ({
          ...item,
          title: query ? `${item.title} | ${query}` : item.title
        }));

        resultMeta.textContent = `約 ${(8420000 + query.length * 17321).toLocaleString("ja-JP")} 件中 ${(0.21 + Math.random() * 0.28).toFixed(2)} 秒`;
        resultList.innerHTML = filtered
          .map(
            (item) => `
              <article class="result">
                <div class="result-url">${item.url}</div>
                <h3>${item.title}</h3>
                <p>${item.text}</p>
              </article>
            `
          )
          .join("");
        resultsArea.classList.add("visible");
      }

      function renderCrawlGrid(offset = 0) {
        crawlGrid.innerHTML = crawlItems
          .map(([url, status, progress], index) => {
            const shifted = Math.min(99, Math.max(24, progress + ((offset + index * 7) % 18) - 7));
            return `
              <article class="crawl-card">
                <strong>${url}</strong>
                <span>${status}</span>
                <div class="crawl-progress" aria-label="クロール進捗 ${shifted}%">
                  <i style="--progress: ${shifted}%"></i>
                </div>
              </article>
            `;
          })
          .join("");
      }

      form.addEventListener("submit", (event) => {
        event.preventDefault();
        renderResults(queryInput.value.trim() || "クローラー");
      });

      crawlButton.addEventListener("click", () => {
        renderCrawlGrid(Math.floor(Math.random() * 20));
        renderResults(queryInput.value.trim() || "live crawl");
        document.querySelector("#crawler").scrollIntoView({ behavior: "smooth", block: "start" });
      });

      voiceButton.addEventListener("click", () => {
        queryInput.value = "最新のインデックス状況";
        queryInput.focus();
      });

      renderCrawlGrid();
    </script>
  </body>
</html>

ネタ神AI Pro – アイデアメーカー

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ネタ神AI Pro - アイデアメーカー</title>

  <style>
    :root {
      --bg: #070914;
      --bg2: #111831;
      --card: rgba(255, 255, 255, 0.08);
      --card2: rgba(255, 255, 255, 0.13);
      --text: #f5f7ff;
      --muted: #aeb8df;
      --line: rgba(255, 255, 255, 0.16);
      --primary: #7c5cff;
      --cyan: #00d4ff;
      --green: #38ffad;
      --yellow: #ffd35c;
      --red: #ff5c7c;
      --shadow: 0 22px 55px rgba(0, 0, 0, 0.35);
      --radius: 22px;
    }

    body.light {
      --bg: #eef2ff;
      --bg2: #ffffff;
      --card: rgba(255, 255, 255, 0.8);
      --card2: rgba(255, 255, 255, 0.95);
      --text: #151829;
      --muted: #566179;
      --line: rgba(20, 30, 60, 0.14);
      --shadow: 0 18px 45px rgba(40, 60, 110, 0.16);
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;
      font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic", sans-serif;
      color: var(--text);
      background:
        radial-gradient(circle at 20% 10%, rgba(124, 92, 255, 0.32), transparent 28%),
        radial-gradient(circle at 90% 20%, rgba(0, 212, 255, 0.24), transparent 28%),
        radial-gradient(circle at 50% 100%, rgba(255, 92, 124, 0.15), transparent 32%),
        linear-gradient(135deg, var(--bg), var(--bg2));
      transition: 0.25s;
    }

    button,
    input,
    select,
    textarea {
      font-family: inherit;
    }

    button {
      border: 1px solid var(--line);
      border-radius: 14px;
      background: var(--card2);
      color: var(--text);
      padding: 11px 14px;
      font-weight: 800;
      cursor: pointer;
      transition: 0.2s;
      backdrop-filter: blur(12px);
    }

    button:hover {
      transform: translateY(-2px);
      filter: brightness(1.08);
    }

    .btn-main {
      border: none;
      background: linear-gradient(135deg, var(--primary), var(--cyan));
      color: white;
      box-shadow: 0 16px 35px rgba(0, 212, 255, 0.2);
    }

    .btn-green {
      border: none;
      background: linear-gradient(135deg, #13bf84, var(--green));
      color: #06120d;
    }

    .btn-red {
      border-color: rgba(255, 92, 124, 0.4);
      background: rgba(255, 92, 124, 0.14);
    }

    .app {
      width: min(1380px, 94%);
      margin: 0 auto;
      padding: 28px 0 70px;
    }

    header {
      display: flex;
      justify-content: space-between;
      gap: 18px;
      align-items: center;
      margin-bottom: 22px;
    }

    .brand {
      display: flex;
      align-items: center;
      gap: 14px;
    }

    .logo {
      width: 58px;
      height: 58px;
      border-radius: 20px;
      background: linear-gradient(135deg, var(--primary), var(--cyan));
      display: grid;
      place-items: center;
      font-size: 30px;
      box-shadow: 0 20px 45px rgba(124, 92, 255, 0.35);
    }

    h1, h2, h3, h4, p {
      margin-top: 0;
    }

    .brand h1 {
      margin: 0;
      font-size: clamp(27px, 4vw, 46px);
      letter-spacing: 0.03em;
    }

    .brand p {
      margin: 4px 0 0;
      color: var(--muted);
      font-size: 14px;
    }

    .header-actions {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      justify-content: flex-end;
    }

    .hero {
      border: 1px solid var(--line);
      background: var(--card);
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      backdrop-filter: blur(16px);
      padding: 26px;
      margin-bottom: 22px;
      overflow: hidden;
      position: relative;
    }

    .hero::after {
      content: "";
      position: absolute;
      width: 320px;
      height: 320px;
      right: -120px;
      bottom: -160px;
      border-radius: 50%;
      background: rgba(0, 212, 255, 0.13);
      filter: blur(8px);
    }

    .hero h2 {
      font-size: clamp(24px, 3vw, 40px);
      margin-bottom: 10px;
      line-height: 1.35;
    }

    .hero p {
      color: var(--muted);
      line-height: 1.8;
      max-width: 900px;
      margin-bottom: 0;
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 14px;
      margin-bottom: 22px;
    }

    .stat {
      border: 1px solid var(--line);
      background: var(--card);
      border-radius: 18px;
      padding: 16px;
      box-shadow: var(--shadow);
    }

    .stat strong {
      display: block;
      font-size: 24px;
      margin-bottom: 3px;
    }

    .stat span {
      color: var(--muted);
      font-size: 13px;
    }

    .layout {
      display: grid;
      grid-template-columns: 420px 1fr;
      gap: 22px;
      align-items: start;
    }

    .panel {
      border: 1px solid var(--line);
      background: var(--card);
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      backdrop-filter: blur(16px);
      overflow: hidden;
    }

    .panel-header {
      padding: 18px 20px;
      border-bottom: 1px solid var(--line);
      display: flex;
      justify-content: space-between;
      gap: 12px;
      align-items: center;
    }

    .panel-header h3 {
      margin: 0;
      font-size: 19px;
    }

    .panel-body {
      padding: 20px;
    }

    .badge {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 6px 10px;
      border-radius: 999px;
      background: linear-gradient(135deg, var(--green), var(--cyan));
      color: #06121c;
      font-size: 12px;
      font-weight: 900;
      white-space: nowrap;
    }

    .form-group {
      margin-bottom: 16px;
    }

    label {
      display: block;
      color: var(--muted);
      font-size: 13px;
      font-weight: 800;
      margin-bottom: 8px;
    }

    input,
    select,
    textarea {
      width: 100%;
      border: 1px solid var(--line);
      background: rgba(0, 0, 0, 0.22);
      color: var(--text);
      border-radius: 14px;
      padding: 12px 13px;
      outline: none;
      font-size: 15px;
      transition: 0.2s;
    }

    body.light input,
    body.light select,
    body.light textarea {
      background: rgba(255, 255, 255, 0.85);
    }

    select option {
      background: #10162a;
      color: white;
    }

    input:focus,
    select:focus,
    textarea:focus {
      border-color: rgba(0, 212, 255, 0.85);
      box-shadow: 0 0 0 4px rgba(0, 212, 255, 0.12);
    }

    textarea {
      min-height: 105px;
      resize: vertical;
      line-height: 1.7;
    }

    .two {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 12px;
    }

    .chips {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 10px;
    }

    .chip {
      border: 1px solid var(--line);
      background: rgba(255, 255, 255, 0.08);
      color: var(--muted);
      border-radius: 999px;
      padding: 8px 10px;
      font-size: 12px;
      font-weight: 800;
      cursor: pointer;
      transition: 0.2s;
    }

    .chip:hover {
      color: var(--text);
      border-color: rgba(0, 212, 255, 0.7);
      transform: translateY(-1px);
    }

    .button-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
      margin-top: 16px;
    }

    .button-grid .wide-btn {
      grid-column: 1 / -1;
    }

    .result-tools {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      justify-content: flex-end;
    }

    .empty {
      text-align: center;
      padding: 70px 24px;
      color: var(--muted);
    }

    .empty .icon {
      font-size: 64px;
      margin-bottom: 12px;
    }

    .idea-list {
      display: grid;
      gap: 16px;
    }

    .idea-card {
      border: 1px solid var(--line);
      background: rgba(0, 0, 0, 0.18);
      border-radius: 20px;
      overflow: hidden;
    }

    body.light .idea-card {
      background: rgba(255, 255, 255, 0.78);
    }

    .idea-top {
      padding: 20px;
      border-bottom: 1px solid var(--line);
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 15px;
      align-items: start;
    }

    .idea-title {
      margin: 0;
      font-size: clamp(24px, 3vw, 36px);
      line-height: 1.25;
    }

    .meta {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 10px;
    }

    .pill {
      font-size: 12px;
      font-weight: 900;
      padding: 6px 9px;
      border-radius: 999px;
      border: 1px solid var(--line);
      color: var(--muted);
      background: rgba(255,255,255,0.07);
    }

    .score-box {
      width: 96px;
      text-align: center;
      padding: 12px;
      border-radius: 18px;
      background: linear-gradient(135deg, rgba(124, 92, 255, 0.35), rgba(0, 212, 255, 0.24));
      border: 1px solid var(--line);
    }

    .score-box strong {
      display: block;
      font-size: 26px;
    }

    .score-box span {
      color: var(--muted);
      font-size: 12px;
      font-weight: 800;
    }

    .catch {
      padding: 16px 20px;
      font-size: 17px;
      line-height: 1.7;
      background: rgba(255, 255, 255, 0.07);
      border-bottom: 1px solid var(--line);
    }

    .idea-body {
      padding: 20px;
    }

    .sections {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 14px;
    }

    .section {
      border: 1px solid var(--line);
      border-radius: 17px;
      padding: 16px;
      background: rgba(0, 0, 0, 0.18);
    }

    body.light .section {
      background: rgba(255, 255, 255, 0.62);
    }

    .section.wide {
      grid-column: 1 / -1;
    }

    .section h4 {
      margin: 0 0 10px;
      font-size: 15px;
    }

    .section p,
    .section li {
      color: var(--muted);
      line-height: 1.75;
      font-size: 14px;
    }

    .section p {
      margin-bottom: 0;
    }

    .section ul,
    .section ol {
      margin: 0;
      padding-left: 22px;
    }

    .idea-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      padding: 0 20px 20px;
    }

    .history-controls {
      display: grid;
      grid-template-columns: 1fr 180px;
      gap: 10px;
      margin-bottom: 14px;
    }

    .history-list {
      display: grid;
      gap: 10px;
    }

    .history-item {
      border: 1px solid var(--line);
      border-radius: 16px;
      padding: 13px;
      cursor: pointer;
      background: rgba(0, 0, 0, 0.15);
      transition: 0.2s;
    }

    body.light .history-item {
      background: rgba(255, 255, 255, 0.7);
    }

    .history-item:hover {
      transform: translateY(-2px);
      border-color: rgba(0, 212, 255, 0.6);
    }

    .history-item strong {
      display: block;
      margin-bottom: 5px;
    }

    .history-item small {
      color: var(--muted);
    }

    .toast {
      position: fixed;
      right: 20px;
      bottom: 20px;
      background: rgba(10, 15, 30, 0.94);
      color: white;
      border: 1px solid rgba(255,255,255,0.16);
      border-radius: 16px;
      padding: 14px 18px;
      box-shadow: var(--shadow);
      opacity: 0;
      transform: translateY(20px);
      pointer-events: none;
      transition: 0.25s;
      z-index: 100;
    }

    .toast.show {
      opacity: 1;
      transform: translateY(0);
    }

    .footer {
      margin-top: 28px;
      text-align: center;
      color: var(--muted);
      font-size: 13px;
    }

    @media (max-width: 1050px) {
      header {
        flex-direction: column;
        align-items: flex-start;
      }

      .layout {
        grid-template-columns: 1fr;
      }

      .stats {
        grid-template-columns: repeat(2, 1fr);
      }

      .sections {
        grid-template-columns: 1fr;
      }

      .idea-top {
        grid-template-columns: 1fr;
      }

      .score-box {
        width: 100%;
      }
    }

    @media (max-width: 620px) {
      .two,
      .button-grid,
      .history-controls,
      .stats {
        grid-template-columns: 1fr;
      }

      .header-actions {
        width: 100%;
      }

      .header-actions button {
        flex: 1;
      }
    }
  </style>
</head>

<body>
  <div class="app">
    <header>
      <div class="brand">
        <div class="logo">💡</div>
        <div>
          <h1>ネタ神AI Pro</h1>
          <p>API不要。ブラウザだけで動く創作・Webサービス企画メーカー</p>
        </div>
      </div>

      <div class="header-actions">
        <button onclick="toggleTheme()">テーマ切替</button>
        <button onclick="downloadText()">TXT出力</button>
        <button onclick="downloadJSON()">JSON出力</button>
        <button class="btn-red" onclick="clearAll()">全削除</button>
      </div>
    </header>

    <section class="hero">
      <h2>APIなしでも、かなり使える「企画書生成ツール」にする。</h2>
      <p>
        外部AIに接続せず、ローカルのテンプレート・ランダム生成・条件分岐だけで、Webサービス、AIツール、ゲーム、小説、SNSなどの企画案を作ります。
        API料金もキー管理も不要です。まず作品として公開しやすい形です。
      </p>
    </section>

    <section class="stats">
      <div class="stat">
        <strong id="statIdeas">0</strong>
        <span>今回生成した案</span>
      </div>
      <div class="stat">
        <strong id="statSaved">0</strong>
        <span>保存済みアイデア</span>
      </div>
      <div class="stat">
        <strong>0円</strong>
        <span>API利用料</span>
      </div>
      <div class="stat">
        <strong>100%</strong>
        <span>ローカル動作</span>
      </div>
    </section>

    <main class="layout">
      <section class="panel">
        <div class="panel-header">
          <h3>生成条件</h3>
          <span class="badge">NO API</span>
        </div>

        <div class="panel-body">
          <div class="two">
            <div class="form-group">
              <label for="genre">ジャンル</label>
              <select id="genre">
                <option>Webサービス</option>
                <option>AIツール</option>
                <option>ゲーム</option>
                <option>小説</option>
                <option>SNS</option>
                <option>動画サイト</option>
                <option>ポートフォリオ</option>
                <option>便利ツール</option>
                <option>学習サービス</option>
                <option>創作支援</option>
              </select>
            </div>

            <div class="form-group">
              <label for="mood">雰囲気</label>
              <select id="mood">
                <option>かっこいい</option>
                <option>やさしい</option>
                <option>近未来</option>
                <option>ファンタジー</option>
                <option>シンプル</option>
                <option>高級感</option>
                <option>かわいい</option>
                <option>ダーク</option>
                <option>実用的</option>
                <option>ゲーム風</option>
              </select>
            </div>
          </div>

          <div class="two">
            <div class="form-group">
              <label for="level">開発難易度</label>
              <select id="level">
                <option>簡単</option>
                <option>普通</option>
                <option>本格</option>
                <option>超本格</option>
              </select>
            </div>

            <div class="form-group">
              <label for="target">ターゲット</label>
              <select id="target">
                <option>個人クリエイター</option>
                <option>学生</option>
                <option>社会人</option>
                <option>在宅ワーカー</option>
                <option>ゲーム制作者</option>
                <option>小説家志望</option>
                <option>配信者</option>
                <option>初心者</option>
                <option>副業したい人</option>
              </select>
            </div>
          </div>

          <div class="two">
            <div class="form-group">
              <label for="amount">生成数</label>
              <select id="amount">
                <option value="1">1個</option>
                <option value="3" selected>3個</option>
                <option value="5">5個</option>
              </select>
            </div>

            <div class="form-group">
              <label for="style">出力スタイル</label>
              <select id="style">
                <option>企画書風</option>
                <option>サービス紹介風</option>
                <option>開発メモ風</option>
                <option>ピッチ資料風</option>
              </select>
            </div>
          </div>

          <div class="form-group">
            <label for="keywords">キーワード</label>
            <textarea id="keywords" placeholder="例:AI / RPG / SNS / メモ / 仕事 / 創作 / ポートフォリオ"></textarea>

            <div class="chips">
              <span class="chip" onclick="addKeyword('AI')">AI</span>
              <span class="chip" onclick="addKeyword('RPG')">RPG</span>
              <span class="chip" onclick="addKeyword('SNS')">SNS</span>
              <span class="chip" onclick="addKeyword('小説')">小説</span>
              <span class="chip" onclick="addKeyword('仕事')">仕事</span>
              <span class="chip" onclick="addKeyword('メモ')">メモ</span>
              <span class="chip" onclick="addKeyword('ポートフォリオ')">ポートフォリオ</span>
              <span class="chip" onclick="addKeyword('動画')">動画</span>
              <span class="chip" onclick="addKeyword('学習')">学習</span>
              <span class="chip" onclick="addKeyword('ゲーム開発')">ゲーム開発</span>
            </div>
          </div>

          <div class="form-group">
            <label for="problem">解決したい悩み</label>
            <textarea id="problem" placeholder="例:何を作ればいいかわからない。作業が続かない。アイデアを整理できない。"></textarea>
          </div>

          <div class="button-grid">
            <button class="btn-main wide-btn" onclick="generateIdeas()">アイデア生成</button>
            <button onclick="randomSet()">ランダム条件</button>
            <button onclick="makePractical()">現実的にする</button>
            <button onclick="makeFantasy()">派手にする</button>
            <button onclick="clearForm()">入力クリア</button>
          </div>
        </div>
      </section>

      <section class="panel">
        <div class="panel-header">
          <h3>生成結果</h3>
          <div class="result-tools">
            <button onclick="copyAll()">コピー</button>
            <button class="btn-green" onclick="saveAll()">全部保存</button>
          </div>
        </div>

        <div class="panel-body">
          <div id="result">
            <div class="empty">
              <div class="icon">🧠</div>
              <h2>まだアイデアはありません</h2>
              <p>左の条件を入れて「アイデア生成」を押してください。</p>
            </div>
          </div>
        </div>
      </section>
    </main>

    <section class="panel" style="margin-top:22px;">
      <div class="panel-header">
        <h3>保存したアイデア</h3>
        <span class="badge" id="savedCount">0件</span>
      </div>

      <div class="panel-body">
        <div class="history-controls">
          <input id="historySearch" placeholder="保存アイデアを検索" oninput="renderHistory()" />
          <select id="historyGenre" onchange="renderHistory()">
            <option value="all">全ジャンル</option>
            <option>Webサービス</option>
            <option>AIツール</option>
            <option>ゲーム</option>
            <option>小説</option>
            <option>SNS</option>
            <option>動画サイト</option>
            <option>ポートフォリオ</option>
            <option>便利ツール</option>
            <option>学習サービス</option>
            <option>創作支援</option>
          </select>
        </div>

        <div class="history-list" id="historyList"></div>
      </div>
    </section>

    <div class="footer">
      ネタ神AI Pro / APIなしローカル版 / HTML・CSS・JavaScriptのみ
    </div>
  </div>

  <div class="toast" id="toast">完了しました</div>

  <script>
    const DATA = {
      titleHeads: [
        "Nova", "Idea", "Neta", "Mira", "Chrono", "Elder", "Prompt", "Vision",
        "Craft", "Yume", "Neo", "Astra", "Luna", "Meta", "Spark", "Quest"
      ],

      titleTails: {
        "Webサービス": ["Hub", "Works", "Base", "Cloud", "Studio", "Panel", "Link", "Board"],
        "AIツール": ["AI", "Brain", "Agent", "Prompt", "Copilot", "Mind", "Assist", "Genius"],
        "ゲーム": ["Quest", "Chronicle", "Saga", "Blade", "Dungeon", "Legend", "Arc", "World"],
        "小説": ["Novel", "Story", "Tale", "Script", "Lore", "Ink", "Scene", "Dream"],
        "SNS": ["Verse", "Circle", "Post", "Talk", "Room", "Link", "Wave", "Nest"],
        "動画サイト": ["Tube", "Stream", "Clip", "Vision", "Cast", "Channel", "View", "Media"],
        "ポートフォリオ": ["Portfolio", "Gallery", "Works", "Profile", "Card", "Showcase", "Archive", "Page"],
        "便利ツール": ["Tool", "Memo", "Desk", "Kit", "Task", "Quick", "Utility", "Simple"],
        "学習サービス": ["Learn", "Study", "Lesson", "Skill", "Academy", "Trainer", "Coach", "Note"],
        "創作支援": ["Create", "Maker", "Muse", "Seed", "Craft", "Atelier", "Generator", "Factory"]
      },

      moodDesc: {
        "かっこいい": "鋭く洗練された印象で、使うだけで制作意欲が上がる",
        "やさしい": "初心者でも迷わない、安心感のある",
        "近未来": "AI時代らしい自動化と先進性を感じる",
        "ファンタジー": "クエストやギルドのような世界観を活かした",
        "シンプル": "余計な機能を削り、すぐ使えることに集中した",
        "高級感": "プロ向けツールのように落ち着いた印象の",
        "かわいい": "親しみやすく、毎日開きたくなる",
        "ダーク": "深い世界観と中二感を活かした",
        "実用的": "仕事や制作の効率化に直結する",
        "ゲーム風": "レベル、経験値、クエストのような要素を持つ"
      },

      features: {
        "Webサービス": ["ユーザー投稿", "検索", "タグ分類", "お気に入り", "ランキング", "管理画面", "コメント", "カテゴリ管理", "共有リンク", "レスポンシブUI"],
        "AIツール": ["文章生成", "テンプレート選択", "プロンプト保存", "履歴管理", "自動分類", "要約", "言い換え", "コピー", "お気に入り", "出力形式変更"],
        "ゲーム": ["キャラクター管理", "クエスト", "ステージ選択", "スキル", "装備", "敵図鑑", "ストーリー分岐", "進行度保存", "称号", "実績"],
        "小説": ["キャラ設定", "世界観管理", "章立て", "プロット", "セリフ案", "伏線メモ", "用語集", "文体変換", "シーン整理", "年表"],
        "SNS": ["タイムライン", "投稿", "いいね", "フォロー", "通知", "プロフィール", "ハッシュタグ", "DM風UI", "おすすめ投稿", "AI投稿提案"],
        "動画サイト": ["動画カード", "検索", "カテゴリ", "ランキング", "チャンネル", "視聴履歴", "コメント", "お気に入り", "おすすめ", "タグ"],
        "ポートフォリオ": ["作品カード", "リンク管理", "カテゴリ分類", "紹介文生成", "スキル表示", "実績一覧", "検索", "テーマ変更", "外部リンク", "更新履歴"],
        "便利ツール": ["メモ", "ToDo", "検索", "タグ", "自動整形", "コピー", "履歴", "エクスポート", "チェックリスト", "通知風表示"],
        "学習サービス": ["学習記録", "復習リスト", "クイズ", "用語集", "進捗", "AI風解説", "弱点メモ", "計画作成", "達成バッジ", "問題生成"],
        "創作支援": ["アイデア生成", "タイトル案", "キャッチコピー", "キャラ案", "世界観案", "企画書化", "画像プロンプト", "構成案", "メモ保存", "ネタ帳"]
      },

      monetization: [
        "無料版+Pro版",
        "広告表示",
        "買い切り版",
        "テンプレート販売",
        "月額プレミアム",
        "法人向けプラン",
        "追加保存枠の課金",
        "作品公開ページの有料カスタム",
        "支援・投げ銭",
        "素材パック販売"
      ],

      risks: [
        "機能を増やしすぎると完成しにくくなる",
        "最初からログインや課金を入れると開発が重くなる",
        "ターゲットが広すぎると特徴が薄くなる",
        "保存機能の設計を後回しにすると作り直しが出やすい",
        "見た目だけ作って実用性が弱いと使われにくい",
        "スマホ対応を忘れると使い勝手が落ちる"
      ],

      firstSteps: {
        "簡単": ["1画面UIを作る", "入力欄と生成ボタンを作る", "結果表示を作る", "コピー機能を付ける", "ローカル保存を付ける"],
        "普通": ["基本UIを作る", "複数パターン生成を作る", "履歴保存を作る", "検索と絞り込みを作る", "テキスト出力を作る"],
        "本格": ["MVPを作る", "保存データ構造を決める", "ログインなし版を完成させる", "ユーザー登録版を検討する", "公開ページを整える"],
        "超本格": ["小さいMVPを先に作る", "フロントとバックエンドを分ける", "DB設計をする", "課金やログインを後から追加する", "運用コストを確認する"]
      }
    };

    let currentIdeas = [];
    let generatedCount = 0;

    function $(id) {
      return document.getElementById(id);
    }

    function val(id) {
      return $(id).value.trim();
    }

    function pick(arr) {
      return arr[Math.floor(Math.random() * arr.length)];
    }

    function shuffle(arr) {
      return [...arr].sort(() => Math.random() - 0.5);
    }

    function escapeHTML(str) {
      return String(str)
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#039;");
    }

    function addKeyword(word) {
      const box = $("keywords");
      if (!box.value.includes(word)) {
        box.value = box.value ? box.value + " / " + word : word;
      }
    }

    function randomSet() {
      randomSelect("genre");
      randomSelect("mood");
      randomSelect("level");
      randomSelect("target");
      randomSelect("style");

      const sets = [
        "AI / メモ / 作業効率",
        "RPG / クエスト / 進捗管理",
        "SNS / 投稿 / AI風返信",
        "小説 / 世界観 / キャラクター",
        "在宅ワーク / 日報 / 整理",
        "ポートフォリオ / 作品 / 自動紹介",
        "動画 / まとめ / ランキング",
        "学習 / 復習 / クイズ",
        "ゲーム開発 / アイデア / 仕様書",
        "創作 / ネタ帳 / 企画書"
      ];

      const problems = [
        "何を作ればいいかわからない",
        "作業が続かない",
        "アイデアが散らばって整理できない",
        "作品紹介文を書くのが難しい",
        "学習した内容を忘れやすい",
        "毎日の進捗を見える化したい",
        "企画を作っても途中で止まりやすい"
      ];

      $("keywords").value = pick(sets);
      $("problem").value = pick(problems);
      showToast("ランダム条件を入れました");
    }

    function randomSelect(id) {
      const el = $(id);
      el.selectedIndex = Math.floor(Math.random() * el.options.length);
    }

    function makePractical() {
      $("mood").value = "実用的";
      $("level").value = "簡単";
      $("style").value = "開発メモ風";
      if (!$("problem").value) {
        $("problem").value = "毎日の作業やアイデアを整理して、次にやることを明確にしたい";
      }
      showToast("現実的な条件に寄せました");
    }

    function makeFantasy() {
      $("mood").value = "ファンタジー";
      $("style").value = "サービス紹介風";
      if (!$("keywords").value.includes("ギルド")) {
        addKeyword("ギルド");
        addKeyword("クエスト");
      }
      showToast("派手な条件に寄せました");
    }

    function clearForm() {
      $("keywords").value = "";
      $("problem").value = "";
      showToast("入力をクリアしました");
    }

    function generateIdeas() {
      const amount = Number(val("amount"));
      currentIdeas = [];

      for (let i = 0; i < amount; i++) {
        currentIdeas.push(createIdea(i));
      }

      generatedCount += amount;
      $("statIdeas").textContent = generatedCount;
      renderIdeas();
      showToast(`${amount}個のアイデアを生成しました`);
    }

    function createIdea(index) {
      const genre = val("genre");
      const mood = val("mood");
      const level = val("level");
      const target = val("target");
      const style = val("style");
      const keywords = val("keywords") || "AI / 創作 / アイデア";
      const problem = val("problem") || "アイデアを整理して、作り始めやすくしたい";

      const title = makeTitle(genre, keywords, index);
      const features = shuffle(DATA.features[genre]).slice(0, 6);
      const mvp = features.slice(0, 3);
      const money = shuffle(DATA.monetization).slice(0, 3);
      const risks = shuffle(DATA.risks).slice(0, 3);
      const steps = DATA.firstSteps[level];
      const score = calcScore(genre, level);
      const keywordMain = splitKeywords(keywords)[0] || "アイデア";

      return {
        id: Date.now() + Math.random(),
        createdAt: new Date().toLocaleString("ja-JP"),
        title,
        genre,
        mood,
        level,
        target,
        style,
        keywords,
        problem,
        score,
        catchcopy: makeCatch(target, mood, genre, keywordMain),
        overview: makeOverview(genre, mood, target, keywords, problem, style),
        unique: makeUnique(genre, mood, keywordMain),
        features,
        mvp,
        money,
        risks,
        steps,
        devTime: makeDevTime(level),
        nextAction: makeNextAction(level, genre),
        design: makeDesign(mood),
        pitch: makePitch(title, target, genre, problem)
      };
    }

    function splitKeywords(text) {
      return text.split(/[\/、,\s]+/).map(x => x.trim()).filter(Boolean);
    }

    function makeTitle(genre, keywords, index) {
      const keys = splitKeywords(keywords);
      const key = keys[index % Math.max(keys.length, 1)] || "Idea";
      const head = pick(DATA.titleHeads);
      const tail = pick(DATA.titleTails[genre] || DATA.titleTails["創作支援"]);

      const patterns = [
        `${head}${tail}`,
        `${key}${tail}`,
        `${head} ${tail}`,
        `${key}メーカー`,
        `${key}ギルド`,
        `${key}Forge`,
        `${head}ノート`,
        `${key}ラボ`,
        `${head}Factory`,
        `${key}クエスト`
      ];

      return pick(patterns);
    }

    function makeCatch(target, mood, genre, key) {
      const desc = DATA.moodDesc[mood];
      const patterns = [
        `${target}の「作りたい」を形にする、${desc}${genre}。`,
        `${key}を起点に、企画・整理・実行まで支える${genre}。`,
        `思いつきを企画に変える、${target}向けの${desc}サービス。`,
        `迷っている時間を減らし、制作を前に進める${genre}。`
      ];
      return pick(patterns);
    }

    function makeOverview(genre, mood, target, keywords, problem, style) {
      const desc = DATA.moodDesc[mood];

      if (style === "ピッチ資料風") {
        return `${problem}という悩みを持つ${target}に向けて、${keywords}を軸にした${genre}を提供します。${desc}体験により、ユーザーはアイデア出しから整理、実行までを短時間で進められます。`;
      }

      if (style === "開発メモ風") {
        return `${keywords}をテーマにした${genre}。まずは小さく作る。${target}が抱える「${problem}」を解決するため、生成、保存、検索、コピーの流れを重視する。`;
      }

      if (style === "サービス紹介風") {
        return `この${genre}は、${target}が${keywords}に関するアイデアをすばやく整理できるサービスです。${desc}デザインで、毎日開きたくなる使い心地を目指します。`;
      }

      return `${keywords}をテーマにした${genre}です。${target}が抱える「${problem}」を解決するため、アイデア出し、情報整理、保存、次の行動提案をまとめて行える企画にします。`;
    }

    function makeUnique(genre, mood, key) {
      return `${key}をただ生成するだけでなく、MVP、開発手順、収益化、注意点まで同時に出せる点が特徴です。${mood}な方向性を明確にすることで、似たような${genre}との差別化もしやすくなります。`;
    }

    function calcScore(genre, level) {
      let score = 82;

      if (level === "簡単") score += 10;
      if (level === "普通") score += 5;
      if (level === "本格") score -= 2;
      if (level === "超本格") score -= 9;

      if (genre === "便利ツール") score += 4;
      if (genre === "AIツール") score += 3;
      if (genre === "ゲーム") score -= 4;
      if (genre === "SNS") score -= 3;

      score += Math.floor(Math.random() * 9) - 4;

      return Math.max(55, Math.min(98, score));
    }

    function makeDevTime(level) {
      if (level === "簡単") return "1日〜3日";
      if (level === "普通") return "1週間〜2週間";
      if (level === "本格") return "1か月〜3か月";
      return "3か月以上";
    }

    function makeNextAction(level, genre) {
      if (level === "簡単") {
        return `まずは${genre}の1画面版を作ります。入力欄、生成ボタン、結果表示、保存だけで完成扱いにするのが安全です。`;
      }

      if (level === "普通") {
        return `最初にUIを作り、そのあと保存・検索・出力機能を追加します。ログイン機能は後回しで大丈夫です。`;
      }

      if (level === "本格") {
        return `MVPを公開できる状態まで作ってから、ユーザー登録やデータベースを検討します。最初から全部入れない方が完成します。`;
      }

      return `超本格版は重いので、まずはプロトタイプを完成させてください。完成後にサーバー、DB、課金、ログインを分割して追加する流れが安全です。`;
    }

    function makeDesign(mood) {
      const map = {
        "かっこいい": "黒背景、青紫グラデーション、カード型UI、シャープなボタン",
        "やさしい": "白背景、淡い青や緑、角丸カード、大きめ文字",
        "近未来": "ダーク背景、ネオン、水色アクセント、ガラス風UI",
        "ファンタジー": "羊皮紙風、ギルドカード、クエストボード風UI",
        "シンプル": "白背景、余白多め、入力欄と結果表示を中心にする",
        "高級感": "黒と金、細い罫線、落ち着いたカードUI",
        "かわいい": "パステルカラー、丸いボタン、アイコン多め",
        "ダーク": "黒、赤紫、重厚な影、世界観重視",
        "実用的": "管理画面風、見出し明確、コピー・保存ボタンを目立たせる",
        "ゲーム風": "ステータス画面、経験値バー、クエストカード風"
      };

      return map[mood] || "カード型で見やすいUI";
    }

    function makePitch(title, target, genre, problem) {
      return `${title}は、${target}が抱える「${problem}」を解決する${genre}です。複雑な作業を整理し、次にやることを明確にすることで、制作や仕事を止めずに進められるようにします。`;
    }

    function renderIdeas() {
      const result = $("result");

      if (currentIdeas.length === 0) {
        result.innerHTML = `
          <div class="empty">
            <div class="icon">🧠</div>
            <h2>まだアイデアはありません</h2>
            <p>左の条件を入れて「アイデア生成」を押してください。</p>
          </div>
        `;
        return;
      }

      result.innerHTML = `
        <div class="idea-list">
          ${currentIdeas.map(renderIdeaCard).join("")}
        </div>
      `;
    }

    function renderIdeaCard(idea, index) {
      return `
        <article class="idea-card">
          <div class="idea-top">
            <div>
              <h2 class="idea-title">${escapeHTML(idea.title)}</h2>
              <div class="meta">
                <span class="pill">${escapeHTML(idea.genre)}</span>
                <span class="pill">${escapeHTML(idea.mood)}</span>
                <span class="pill">${escapeHTML(idea.level)}</span>
                <span class="pill">${escapeHTML(idea.target)}</span>
                <span class="pill">開発目安 ${escapeHTML(idea.devTime)}</span>
              </div>
            </div>

            <div class="score-box">
              <strong>${idea.score}</strong>
              <span>実現度</span>
            </div>
          </div>

          <div class="catch">
            ${escapeHTML(idea.catchcopy)}
          </div>

          <div class="idea-body">
            <div class="sections">
              <div class="section wide">
                <h4>📝 概要</h4>
                <p>${escapeHTML(idea.overview)}</p>
              </div>

              <div class="section">
                <h4>🎯 解決する悩み</h4>
                <p>${escapeHTML(idea.problem)}</p>
              </div>

              <div class="section">
                <h4>🎨 デザイン方針</h4>
                <p>${escapeHTML(idea.design)}</p>
              </div>

              <div class="section">
                <h4>⚙️ 主な機能</h4>
                <ul>
                  ${idea.features.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
                </ul>
              </div>

              <div class="section">
                <h4>🚀 MVP機能</h4>
                <ol>
                  ${idea.mvp.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
                </ol>
              </div>

              <div class="section">
                <h4>💰 収益化案</h4>
                <ul>
                  ${idea.money.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
                </ul>
              </div>

              <div class="section">
                <h4>⚠️ リスク</h4>
                <ul>
                  ${idea.risks.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
                </ul>
              </div>

              <div class="section wide">
                <h4>✅ 開発ステップ</h4>
                <ol>
                  ${idea.steps.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
                </ol>
              </div>

              <div class="section wide">
                <h4>✨ 差別化ポイント</h4>
                <p>${escapeHTML(idea.unique)}</p>
              </div>

              <div class="section wide">
                <h4>📣 紹介文</h4>
                <p>${escapeHTML(idea.pitch)}</p>
              </div>

              <div class="section wide">
                <h4>👉 次にやること</h4>
                <p>${escapeHTML(idea.nextAction)}</p>
              </div>
            </div>
          </div>

          <div class="idea-actions">
            <button onclick="copyOne(${index})">この案をコピー</button>
            <button onclick="saveOne(${index})">保存</button>
            <button onclick="regenerateOne(${index})">この案だけ再生成</button>
          </div>
        </article>
      `;
    }

    function ideaToText(idea) {
      return `
【タイトル】
${idea.title}

【ジャンル】
${idea.genre}

【雰囲気】
${idea.mood}

【ターゲット】
${idea.target}

【開発難易度】
${idea.level}

【開発目安】
${idea.devTime}

【実現度】
${idea.score}点

【キャッチコピー】
${idea.catchcopy}

【解決する悩み】
${idea.problem}

【概要】
${idea.overview}

【主な機能】
${idea.features.map((x, i) => `${i + 1}. ${x}`).join("\n")}

【MVP機能】
${idea.mvp.map((x, i) => `${i + 1}. ${x}`).join("\n")}

【収益化案】
${idea.money.map((x, i) => `${i + 1}. ${x}`).join("\n")}

【リスク】
${idea.risks.map((x, i) => `${i + 1}. ${x}`).join("\n")}

【開発ステップ】
${idea.steps.map((x, i) => `${i + 1}. ${x}`).join("\n")}

【デザイン方針】
${idea.design}

【差別化ポイント】
${idea.unique}

【紹介文】
${idea.pitch}

【次にやること】
${idea.nextAction}
      `.trim();
    }

    function copyOne(index) {
      const idea = currentIdeas[index];
      if (!idea) return;
      copyText(ideaToText(idea));
    }

    function copyAll() {
      if (currentIdeas.length === 0) {
        showToast("先に生成してください");
        return;
      }

      copyText(currentIdeas.map(ideaToText).join("\n\n====================\n\n"));
    }

    function copyText(text) {
      navigator.clipboard.writeText(text)
        .then(() => showToast("コピーしました"))
        .catch(() => showToast("コピーに失敗しました"));
    }

    function regenerateOne(index) {
      currentIdeas[index] = createIdea(index);
      renderIdeas();
      showToast("再生成しました");
    }

    function getSaved() {
      try {
        return JSON.parse(localStorage.getItem("netagami_saved")) || [];
      } catch {
        return [];
      }
    }

    function setSaved(data) {
      localStorage.setItem("netagami_saved", JSON.stringify(data));
      updateStats();
    }

    function saveOne(index) {
      const idea = currentIdeas[index];
      if (!idea) return;

      const saved = getSaved();
      saved.unshift(idea);
      setSaved(saved.slice(0, 100));
      renderHistory();
      showToast("保存しました");
    }

    function saveAll() {
      if (currentIdeas.length === 0) {
        showToast("先に生成してください");
        return;
      }

      const saved = getSaved();
      setSaved([...currentIdeas, ...saved].slice(0, 100));
      renderHistory();
      showToast("全部保存しました");
    }

    function renderHistory() {
      const list = $("historyList");
      const saved = getSaved();
      const q = $("historySearch").value.trim().toLowerCase();
      const genre = $("historyGenre").value;

      let filtered = saved;

      if (genre !== "all") {
        filtered = filtered.filter(x => x.genre === genre);
      }

      if (q) {
        filtered = filtered.filter(x => {
          return JSON.stringify(x).toLowerCase().includes(q);
        });
      }

      $("savedCount").textContent = `${saved.length}件`;

      if (filtered.length === 0) {
        list.innerHTML = `<p style="color:var(--muted);">保存アイデアはありません。</p>`;
        return;
      }

      list.innerHTML = filtered.map(item => `
        <div class="history-item" onclick="loadSaved('${item.id}')">
          <strong>${escapeHTML(item.title)}</strong>
          <small>${escapeHTML(item.genre)} / ${escapeHTML(item.level)} / ${escapeHTML(item.createdAt)}</small>
        </div>
      `).join("");
    }

    function loadSaved(id) {
      const saved = getSaved();
      const idea = saved.find(x => String(x.id) === String(id));

      if (!idea) return;

      currentIdeas = [idea];
      renderIdeas();
      window.scrollTo({ top: 0, behavior: "smooth" });
      showToast("保存アイデアを表示しました");
    }

    function downloadText() {
      if (currentIdeas.length === 0) {
        showToast("先に生成してください");
        return;
      }

      const text = currentIdeas.map(ideaToText).join("\n\n====================\n\n");
      downloadFile("netagami-ideas.txt", text, "text/plain");
      showToast("TXT出力しました");
    }

    function downloadJSON() {
      const data = currentIdeas.length ? currentIdeas : getSaved();

      if (data.length === 0) {
        showToast("出力するデータがありません");
        return;
      }

      downloadFile("netagami-ideas.json", JSON.stringify(data, null, 2), "application/json");
      showToast("JSON出力しました");
    }

    function downloadFile(filename, content, type) {
      const blob = new Blob([content], { type: type + ";charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");

      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();

      URL.revokeObjectURL(url);
    }

    function clearAll() {
      if (!confirm("生成結果と保存履歴を削除しますか?")) return;

      currentIdeas = [];
      localStorage.removeItem("netagami_saved");
      renderIdeas();
      renderHistory();
      updateStats();
      showToast("削除しました");
    }

    function toggleTheme() {
      document.body.classList.toggle("light");
      localStorage.setItem("netagami_theme", document.body.classList.contains("light") ? "light" : "dark");
    }

    function updateStats() {
      $("statSaved").textContent = getSaved().length;
    }

    function showToast(message) {
      const toast = $("toast");
      toast.textContent = message;
      toast.classList.add("show");

      setTimeout(() => {
        toast.classList.remove("show");
      }, 1800);
    }

    function init() {
      if (localStorage.getItem("netagami_theme") === "light") {
        document.body.classList.add("light");
      }

      $("keywords").value = "AI / 創作 / Webサービス / メモ";
      $("problem").value = "何を作ればいいかわからない。アイデアを企画書レベルまで整理したい。";

      renderHistory();
      updateStats();
    }

    init();
  </script>
</body>
</html>

AICharacter掲示板

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>AIキャラ会話掲示板 - Virtual Guild Board</title>
  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
      background: linear-gradient(135deg, #0f172a, #1e293b, #111827);
      color: #e5e7eb;
    }

    header {
      padding: 24px;
      background: rgba(0,0,0,0.35);
      border-bottom: 1px solid rgba(255,255,255,0.08);
      text-align: center;
    }

    header h1 {
      margin: 0;
      font-size: 32px;
      color: #f8fafc;
    }

    header p {
      margin-top: 8px;
      color: #cbd5e1;
      font-size: 14px;
    }

    .container {
      max-width: 1300px;
      margin: 0 auto;
      padding: 20px;
      display: grid;
      grid-template-columns: 300px 1fr 320px;
      gap: 20px;
    }

    .panel {
      background: rgba(255,255,255,0.06);
      border: 1px solid rgba(255,255,255,0.08);
      border-radius: 18px;
      box-shadow: 0 10px 30px rgba(0,0,0,0.25);
      overflow: hidden;
      backdrop-filter: blur(10px);
    }

    .panel-title {
      padding: 16px 18px;
      font-size: 18px;
      font-weight: bold;
      border-bottom: 1px solid rgba(255,255,255,0.08);
      background: rgba(255,255,255,0.04);
    }

    .character-list {
      padding: 14px;
      display: flex;
      flex-direction: column;
      gap: 12px;
      max-height: 720px;
      overflow-y: auto;
    }

    .character-card {
      padding: 14px;
      border-radius: 14px;
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.06);
    }

    .character-card h3 {
      margin: 0 0 6px 0;
      font-size: 17px;
    }

    .character-meta {
      font-size: 13px;
      color: #cbd5e1;
      margin-bottom: 8px;
    }

    .character-desc {
      font-size: 13px;
      color: #e2e8f0;
      line-height: 1.6;
    }

    .main-board {
      display: flex;
      flex-direction: column;
      min-height: 780px;
    }

    .toolbar {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      padding: 14px;
      border-bottom: 1px solid rgba(255,255,255,0.08);
      background: rgba(255,255,255,0.03);
    }

    button, select, input, textarea {
      font: inherit;
    }

    button {
      border: none;
      border-radius: 10px;
      padding: 10px 14px;
      cursor: pointer;
      background: linear-gradient(135deg, #3b82f6, #2563eb);
      color: white;
      transition: 0.2s ease;
    }

    button:hover {
      transform: translateY(-1px);
      filter: brightness(1.08);
    }

    .danger {
      background: linear-gradient(135deg, #ef4444, #dc2626);
    }

    .sub {
      background: linear-gradient(135deg, #64748b, #475569);
    }

    .chat-area {
      flex: 1;
      padding: 18px;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      gap: 14px;
      min-height: 500px;
      max-height: 580px;
    }

    .post {
      display: flex;
      gap: 12px;
      align-items: flex-start;
      padding: 14px;
      border-radius: 16px;
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.06);
      animation: fadeIn 0.25s ease;
    }

    .avatar {
      width: 48px;
      height: 48px;
      min-width: 48px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 22px;
      background: rgba(255,255,255,0.12);
      border: 1px solid rgba(255,255,255,0.12);
    }

    .post-content {
      flex: 1;
    }

    .post-header {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      align-items: center;
      margin-bottom: 6px;
    }

    .name {
      font-weight: bold;
      font-size: 15px;
      color: #ffffff;
    }

    .role {
      font-size: 12px;
      color: #93c5fd;
      background: rgba(59,130,246,0.15);
      padding: 3px 8px;
      border-radius: 999px;
    }

    .time {
      margin-left: auto;
      font-size: 12px;
      color: #94a3b8;
    }

    .message {
      font-size: 15px;
      line-height: 1.75;
      color: #f1f5f9;
      white-space: pre-wrap;
      word-break: break-word;
    }

    .composer {
      padding: 16px;
      border-top: 1px solid rgba(255,255,255,0.08);
      background: rgba(255,255,255,0.03);
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .composer-top {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
    }

    select, input, textarea {
      width: 100%;
      border-radius: 10px;
      border: 1px solid rgba(255,255,255,0.1);
      background: rgba(15,23,42,0.85);
      color: #fff;
      padding: 10px 12px;
      outline: none;
    }

    textarea {
      resize: vertical;
      min-height: 100px;
    }

    .composer-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }

    .right-panel-content {
      padding: 16px;
      display: flex;
      flex-direction: column;
      gap: 16px;
    }

    .status-box, .topic-box, .memory-box {
      padding: 14px;
      border-radius: 14px;
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.06);
    }

    .status-line {
      margin: 8px 0;
      font-size: 14px;
      color: #e2e8f0;
    }

    .topic-tag {
      display: inline-block;
      margin: 6px 6px 0 0;
      padding: 6px 10px;
      border-radius: 999px;
      background: rgba(16,185,129,0.18);
      color: #bbf7d0;
      font-size: 12px;
    }

    .memory-item {
      font-size: 13px;
      padding: 8px 10px;
      margin-top: 8px;
      border-radius: 10px;
      background: rgba(255,255,255,0.04);
      color: #dbeafe;
      line-height: 1.6;
    }

    .footer-note {
      text-align: center;
      color: #94a3b8;
      font-size: 12px;
      padding: 16px;
    }

    .online-dot {
      display: inline-block;
      width: 10px;
      height: 10px;
      border-radius: 50%;
      margin-right: 8px;
      background: #22c55e;
      box-shadow: 0 0 8px #22c55e;
    }

    @keyframes fadeIn {
      from {
        transform: translateY(8px);
        opacity: 0;
      }
      to {
        transform: translateY(0);
        opacity: 1;
      }
    }

    @media (max-width: 1100px) {
      .container {
        grid-template-columns: 1fr;
      }
      .main-board {
        min-height: auto;
      }
      .chat-area {
        max-height: 500px;
      }
    }
  </style>
</head>
<body>
  <header>
    <h1>AIキャラ会話掲示板 - Virtual Guild Board</h1>
    <p>APIなし / ローカル動作 / AIキャラ同士が自動で会話するファンタジー掲示板</p>
  </header>

  <div class="container">
    <!-- 左 -->
    <aside class="panel">
      <div class="panel-title">キャラクター一覧</div>
      <div class="character-list" id="characterList"></div>
    </aside>

    <!-- 中央 -->
    <main class="panel main-board">
      <div class="panel-title">ギルド広場</div>

      <div class="toolbar">
        <button id="toggleAutoBtn">自動会話ON/OFF</button>
        <button id="manualTalkBtn" class="sub">AI会話を1回進める</button>
        <button id="eventBtn" class="sub">イベント発生</button>
        <button id="saveBtn" class="sub">保存</button>
        <button id="clearBtn" class="danger">会話をリセット</button>
      </div>

      <div class="chat-area" id="chatArea"></div>

      <div class="composer">
        <div class="composer-top">
          <select id="userName">
            <option value="旅人">旅人</option>
            <option value="冒険者">冒険者</option>
            <option value="見習い魔法使い">見習い魔法使い</option>
            <option value="傭兵">傭兵</option>
            <option value="吟遊詩人">吟遊詩人</option>
          </select>
        </div>
        <textarea id="userMessage" placeholder="メッセージを書いてください。例:今日は魔王城へ向かうべきかな?"></textarea>
        <div class="composer-actions">
          <button id="sendBtn">投稿する</button>
          <button id="userTriggerBtn" class="sub">投稿後にAI反応</button>
        </div>
      </div>
    </main>

    <!-- 右 -->
    <aside class="panel">
      <div class="panel-title">ワールド情報</div>
      <div class="right-panel-content">
        <div class="status-box">
          <div><span class="online-dot"></span>状態</div>
          <div class="status-line">自動会話: <span id="autoStatus">停止中</span></div>
          <div class="status-line">現在の話題: <span id="currentTopic">雑談</span></div>
          <div class="status-line">投稿数: <span id="postCount">0</span></div>
        </div>

        <div class="topic-box">
          <div><strong>話題タグ</strong></div>
          <div id="topicTags"></div>
        </div>

        <div class="memory-box">
          <div><strong>最近の話題メモ</strong></div>
          <div id="memoryList"></div>
        </div>
      </div>
    </aside>
  </div>

  <div class="footer-note">
    HTML/CSS/JavaScriptのみで動作します。データはブラウザに保存されます。
  </div>

  <script>
    const characters = [
      {
        id: "hero",
        name: "セイン",
        role: "勇者",
        emoji: "⚔️",
        personality: "まっすぐで熱血。前向き。",
        desc: "世界を旅する若き勇者。困っている人を見ると放っておけない。",
        styles: {
          start: ["よし、", "さて、", "うーん、", "そうだな、"],
          end: ["だ!", "だな。", "じゃないか?", "行くしかない!"],
          flavor: ["魔王", "冒険", "仲間", "ダンジョン", "伝説"]
        }
      },
      {
        id: "mage",
        name: "リリィ",
        role: "魔法使い",
        emoji: "🔮",
        personality: "冷静で知的。少し毒舌。",
        desc: "古代魔法を研究している少女。理屈で考えるタイプ。",
        styles: {
          start: ["理論的には、", "その話なら、", "少し気になるのは、", "魔法的に言えば、"],
          end: ["ですね。", "だと思います。", "かもしれません。", "要検証です。"],
          flavor: ["魔法", "精霊", "古代遺跡", "呪文", "研究"]
        }
      },
      {
        id: "knight",
        name: "ガルド",
        role: "騎士",
        emoji: "🛡️",
        personality: "真面目で忠誠心が強い。",
        desc: "王国騎士団に所属する重騎士。秩序と責任を重んじる。",
        styles: {
          start: ["王国のためにも、", "騎士としては、", "規律を守るなら、", "任務として考えると、"],
          end: ["異論はない。", "それが正しい。", "油断は禁物だ。", "準備が必要だ。"],
          flavor: ["王国", "任務", "警戒", "防衛", "規律"]
        }
      },
      {
        id: "merchant",
        name: "ミーナ",
        role: "商人",
        emoji: "💰",
        personality: "明るく現実的。商売人。",
        desc: "各地を巡る行商人。儲け話と珍品に目がない。",
        styles: {
          start: ["それより、", "商売の話をすると、", "利益で考えると、", "ふふっ、"],
          end: ["儲かりそうね。", "悪くないわ。", "値段次第かな。", "面白い商機だわ。"],
          flavor: ["市場", "金貨", "商品", "取引", "珍品"]
        }
      },
      {
        id: "assassin",
        name: "クロウ",
        role: "暗殺者",
        emoji: "🗡️",
        personality: "寡黙でクール。影のある口調。",
        desc: "裏社会で名を知られる暗殺者。静かに本質を突く。",
        styles: {
          start: ["……", "無駄口は嫌いだが、", "影から見る限り、", "静かに言うが、"],
          end: ["それだけだ。", "油断するな。", "匂うな。", "嫌な予感がする。"],
          flavor: ["影", "敵", "罠", "裏路地", "追跡"]
        }
      }
    ];

    const defaultTopics = [
      "魔王討伐",
      "古代遺跡",
      "王国の依頼",
      "森の異変",
      "ギルドの噂",
      "珍しいアイテム",
      "危険なダンジョン",
      "旅の準備",
      "精霊の目撃情報",
      "闇市場"
    ];

    const eventTopics = [
      "城下町で祭りが始まった",
      "北の洞窟にドラゴン出現",
      "謎の商人が秘宝を売っている",
      "王国から緊急依頼が届いた",
      "森で精霊の暴走が起きている",
      "魔王軍の斥候が発見された",
      "夜の港で密輸の噂が広がっている"
    ];

    const generalPhrases = [
      "最近の空気、少し変わった気がする",
      "今日は何か起きそうな予感がある",
      "仲間がいると旅は違う",
      "静かな日ほど何かが起きるものだ",
      "準備を怠ると危ない",
      "噂話にも案外ヒントがある",
      "この町には秘密が多い",
      "力だけでは解決しないこともある",
      "運だけでは生き残れない",
      "今のうちに備えておくべきだ"
    ];

    const replyRules = [
      {
        keywords: ["魔王", "討伐", "倒す"],
        responses: {
          hero: ["魔王を倒せば世界は少しは平和になるはずだ!", "ついに決戦の時かもしれないな!"],
          mage: ["魔王クラスの相手なら準備不足は危険です。", "封印術式も調べておくべきですね。"],
          knight: ["討伐任務なら戦力の整理が必要だ。", "王国への報告も忘れるな。"],
          merchant: ["討伐の前に装備をそろえないと損するわよ。", "その話、特需が出そうね。"],
          assassin: ["魔王より先に側近を潰すべきだ。", "正面から行くのは愚策かもしれない。"]
        }
      },
      {
        keywords: ["金", "お金", "金貨", "報酬"],
        responses: {
          hero: ["報酬も大事だけど、困っている人を助けたいな。", "金だけじゃなく名誉も欲しいところだ!"],
          mage: ["研究費は必要ですからね。", "魔導書は高いので報酬は重要です。"],
          knight: ["報酬より任務達成が優先だ。", "とはいえ補給費は無視できない。"],
          merchant: ["その話なら私の出番ね。", "利益率の高い案件なら乗るわ。"],
          assassin: ["金額次第で動く者も多い。", "報酬の匂いには裏がある。"]
        }
      },
      {
        keywords: ["遺跡", "古代", "秘宝"],
        responses: {
          hero: ["秘宝か……冒険心がくすぐられるな!", "遺跡には夢があるよな!"],
          mage: ["古代遺跡は知識の宝庫です。", "その話、かなり興味があります。"],
          knight: ["遺跡調査には護衛が必要だ。", "罠の警戒を優先しよう。"],
          merchant: ["秘宝は高く売れる可能性があるわね。", "希少品なら市場が動くわ。"],
          assassin: ["遺跡には死人の匂いがする。", "宝より罠を疑え。"]
        }
      },
      {
        keywords: ["森", "精霊", "自然"],
        responses: {
          hero: ["森の異変なら放っておけないな。", "精霊と仲良くできたら心強いな!"],
          mage: ["精霊系の異常反応かもしれません。", "自然魔力の乱れを疑います。"],
          knight: ["森は視界が悪い。隊列を乱すな。", "索敵役が必要だな。"],
          merchant: ["森の特産品が取れなくなるのは困るわ。", "薬草の値段も上がりそう。"],
          assassin: ["森では音と気配に気をつけろ。", "姿の見えない敵ほど厄介だ。"]
        }
      },
      {
        keywords: ["こんにちは", "初めまして", "はじめまして"],
        responses: {
          hero: ["ようこそ!一緒に冒険の話をしよう!", "よろしくな!"],
          mage: ["ようこそ。この掲示板は案外にぎやかですよ。", "初めまして。興味深いですね。"],
          knight: ["歓迎しよう。礼節を守ってくれれば問題ない。", "ここでは情報共有が重要だ。"],
          merchant: ["いらっしゃい。いい情報があれば教えてね。", "歓迎するわ、旅人さん。"],
          assassin: ["……新顔か。好きにするといい。", "静かにしていれば問題ない。"]
        }
      }
    ];

    let posts = [];
    let memoryTopics = [];
    let currentTopic = "雑談";
    let autoTalk = false;
    let autoTimer = null;

    const characterList = document.getElementById("characterList");
    const chatArea = document.getElementById("chatArea");
    const currentTopicEl = document.getElementById("currentTopic");
    const postCountEl = document.getElementById("postCount");
    const autoStatusEl = document.getElementById("autoStatus");
    const topicTagsEl = document.getElementById("topicTags");
    const memoryListEl = document.getElementById("memoryList");

    function renderCharacters() {
      characterList.innerHTML = "";
      characters.forEach(char => {
        const card = document.createElement("div");
        card.className = "character-card";
        card.innerHTML = `
          <h3>${char.emoji} ${char.name}</h3>
          <div class="character-meta">${char.role} / ${char.personality}</div>
          <div class="character-desc">${char.desc}</div>
        `;
        characterList.appendChild(card);
      });
    }

    function getTimeString() {
      const now = new Date();
      return now.toLocaleTimeString("ja-JP", {
        hour: "2-digit",
        minute: "2-digit"
      });
    }

    function addPost(name, role, emoji, message, isUser = false) {
      const post = {
        id: Date.now() + Math.random(),
        name,
        role,
        emoji,
        message,
        time: getTimeString(),
        isUser
      };
      posts.push(post);
      renderPosts();
      saveData();
    }

    function renderPosts() {
      chatArea.innerHTML = "";
      posts.forEach(post => {
        const el = document.createElement("div");
        el.className = "post";
        el.innerHTML = `
          <div class="avatar">${post.emoji}</div>
          <div class="post-content">
            <div class="post-header">
              <div class="name">${escapeHtml(post.name)}</div>
              <div class="role">${escapeHtml(post.role)}</div>
              <div class="time">${post.time}</div>
            </div>
            <div class="message">${escapeHtml(post.message)}</div>
          </div>
        `;
        chatArea.appendChild(el);
      });
      chatArea.scrollTop = chatArea.scrollHeight;
      postCountEl.textContent = posts.length;
    }

    function escapeHtml(text) {
      return text
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#039;");
    }

    function randomItem(arr) {
      return arr[Math.floor(Math.random() * arr.length)];
    }

    function pickCharacter(excludeId = null) {
      const pool = excludeId ? characters.filter(c => c.id !== excludeId) : characters;
      return randomItem(pool);
    }

    function updateTopic(newTopic) {
      currentTopic = newTopic;
      currentTopicEl.textContent = currentTopic;

      memoryTopics.unshift(newTopic);
      memoryTopics = [...new Set(memoryTopics)].slice(0, 8);

      renderTopics();
      renderMemory();
      saveData();
    }

    function renderTopics() {
      topicTagsEl.innerHTML = "";
      const mixed = [currentTopic, ...defaultTopics.slice(0, 6)];
      [...new Set(mixed)].forEach(topic => {
        const tag = document.createElement("span");
        tag.className = "topic-tag";
        tag.textContent = topic;
        topicTagsEl.appendChild(tag);
      });
    }

    function renderMemory() {
      memoryListEl.innerHTML = "";
      if (memoryTopics.length === 0) {
        memoryListEl.innerHTML = `<div class="memory-item">まだ話題メモはありません。</div>`;
        return;
      }
      memoryTopics.forEach(topic => {
        const item = document.createElement("div");
        item.className = "memory-item";
        item.textContent = topic;
        memoryListEl.appendChild(item);
      });
    }

    function buildCharacterSentence(character, topic = currentTopic) {
      const style = character.styles;
      const start = randomItem(style.start);
      const end = randomItem(style.end);
      const flavor = randomItem(style.flavor);
      const phrase = randomItem(generalPhrases);

      const patterns = [
        `${start}${topic}について言えば、${flavor}が鍵になりそう${end}`,
        `${start}${phrase}。特に${flavor}が絡むなら注意${end}`,
        `${start}${topic}の件は気になる。${flavor}の情報を集めたい${end}`,
        `${start}${flavor}を見直した方がいい。${topic}にも繋がる${end}`,
        `${start}${phrase}。${topic}と${flavor}は無関係じゃない${end}`
      ];

      return randomItem(patterns);
    }

    function getRuleBasedReply(inputText, character) {
      const text = inputText.toLowerCase();

      for (const rule of replyRules) {
        const matched = rule.keywords.some(keyword => text.includes(keyword.toLowerCase()));
        if (matched) {
          const responses = rule.responses[character.id];
          if (responses && responses.length > 0) {
            return randomItem(responses);
          }
        }
      }

      return null;
    }

    function extractTopicFromText(text) {
      const found = defaultTopics.find(topic => text.includes(topic.replace("の", ""))) ||
                    eventTopics.find(topic => text.includes(topic.slice(0, 4)));

      if (found) return found;

      if (text.includes("魔王")) return "魔王討伐";
      if (text.includes("遺跡")) return "古代遺跡";
      if (text.includes("森")) return "森の異変";
      if (text.includes("金") || text.includes("報酬")) return "報酬と金貨";
      if (text.includes("王国")) return "王国の依頼";
      if (text.includes("精霊")) return "精霊の目撃情報";
      if (text.includes("ダンジョン")) return "危険なダンジョン";

      return null;
    }

    function aiTalkOnce(previousSpeakerId = null) {
      const speaker = pickCharacter(previousSpeakerId);
      const msg = buildCharacterSentence(speaker, currentTopic);
      addPost(speaker.name, speaker.role, speaker.emoji, msg);
    }

    function aiReplyToText(text, count = 2) {
      const detectedTopic = extractTopicFromText(text);
      if (detectedTopic) updateTopic(detectedTopic);

      let usedIds = [];
      for (let i = 0; i < count; i++) {
        const pool = characters.filter(c => !usedIds.includes(c.id));
        const speaker = randomItem(pool);
        usedIds.push(speaker.id);

        let reply = getRuleBasedReply(text, speaker);
        if (!reply) {
          reply = buildCharacterSentence(speaker, currentTopic);
        }

        addPost(speaker.name, speaker.role, speaker.emoji, reply);
      }
    }

    function generateEvent() {
      const eventText = randomItem(eventTopics);
      updateTopic(eventText);

      addPost("ワールド通知", "システム", "📢", eventText);

      setTimeout(() => {
        aiReplyToText(eventText, 3);
      }, 300);
    }

    function toggleAutoTalk() {
      autoTalk = !autoTalk;
      autoStatusEl.textContent = autoTalk ? "稼働中" : "停止中";

      if (autoTalk) {
        autoTimer = setInterval(() => {
          const count = Math.random() < 0.4 ? 2 : 1;
          let prevId = null;
          for (let i = 0; i < count; i++) {
            const speaker = pickCharacter(prevId);
            prevId = speaker.id;
            const msg = buildCharacterSentence(speaker, currentTopic);
            addPost(speaker.name, speaker.role, speaker.emoji, msg);
          }

          if (Math.random() < 0.28) {
            updateTopic(randomItem(defaultTopics));
          }
        }, 4500);
      } else {
        clearInterval(autoTimer);
      }
      saveData();
    }

    function saveData() {
      const data = {
        posts,
        memoryTopics,
        currentTopic,
        autoTalk
      };
      localStorage.setItem("virtualGuildBoardData", JSON.stringify(data));
    }

    function loadData() {
      const raw = localStorage.getItem("virtualGuildBoardData");
      if (!raw) return false;

      try {
        const data = JSON.parse(raw);
        posts = data.posts || [];
        memoryTopics = data.memoryTopics || [];
        currentTopic = data.currentTopic || "雑談";
        autoTalk = false;
        currentTopicEl.textContent = currentTopic;
        renderPosts();
        renderTopics();
        renderMemory();
        autoStatusEl.textContent = "停止中";
        return true;
      } catch (e) {
        console.error("読み込み失敗", e);
        return false;
      }
    }

    function clearBoard() {
      if (!confirm("会話ログをリセットしますか?")) return;
      posts = [];
      memoryTopics = [];
      currentTopic = "雑談";
      currentTopicEl.textContent = currentTopic;
      renderPosts();
      renderTopics();
      renderMemory();
      saveData();
      addWelcomePosts();
    }

    function addWelcomePosts() {
      addPost("ワールド通知", "システム", "🌍", "Virtual Guild Boardへようこそ。ここではAIキャラたちが自由に会話します。");
      addPost("セイン", "勇者", "⚔️", "よし、今日も冒険の情報を集めよう!");
      addPost("リリィ", "魔法使い", "🔮", "掲示板の反応を見る限り、今日は賑やかになりそうですね。");
      addPost("ミーナ", "商人", "💰", "儲け話でも危険な依頼でも、情報は早い者勝ちよ。");
    }

    document.getElementById("sendBtn").addEventListener("click", () => {
      const userName = document.getElementById("userName").value.trim();
      const userMessage = document.getElementById("userMessage").value.trim();

      if (!userMessage) {
        alert("メッセージを入力してください。");
        return;
      }

      addPost(userName, "プレイヤー", "🧑", userMessage, true);

      const maybeTopic = extractTopicFromText(userMessage);
      if (maybeTopic) {
        updateTopic(maybeTopic);
      }

      document.getElementById("userMessage").value = "";
    });

    document.getElementById("userTriggerBtn").addEventListener("click", () => {
      const userName = document.getElementById("userName").value.trim();
      const userMessage = document.getElementById("userMessage").value.trim();

      if (!userMessage) {
        alert("メッセージを入力してください。");
        return;
      }

      addPost(userName, "プレイヤー", "🧑", userMessage, true);
      aiReplyToText(userMessage, 3);
      document.getElementById("userMessage").value = "";
    });

    document.getElementById("manualTalkBtn").addEventListener("click", () => {
      aiTalkOnce();
    });

    document.getElementById("eventBtn").addEventListener("click", () => {
      generateEvent();
    });

    document.getElementById("toggleAutoBtn").addEventListener("click", () => {
      toggleAutoTalk();
    });

    document.getElementById("saveBtn").addEventListener("click", () => {
      saveData();
      alert("保存しました。");
    });

    document.getElementById("clearBtn").addEventListener("click", () => {
      clearBoard();
    });

    renderCharacters();

    const loaded = loadData();
    if (!loaded || posts.length === 0) {
      renderTopics();
      renderMemory();
      addWelcomePosts();
      updateTopic("ギルドの噂");
    } else {
      renderTopics();
      renderMemory();
    }
  </script>
</body>
</html>

Vooglebrowser


<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Voogle - Mini Browser</title>
<style>
  :root{
    --bg:#0b0f14;
    --panel: rgba(255,255,255,.06);
    --stroke: rgba(255,255,255,.10);
    --ink:#eaf0ff;
    --muted: rgba(234,240,255,.70);
    --accent:#7cf0ff;
    --accent2:#7ca0ff;
    --danger:#ff6b6b;
    --ok:#79ffa7;
    --shadow: 0 18px 50px rgba(0,0,0,.35);
    --radius:16px;
    --radius2:22px;
    --glass: blur(14px) saturate(1.2);
  }
  [data-theme="light"]{
    --bg:#f6f7fb;
    --panel: rgba(0,0,0,.05);
    --stroke: rgba(0,0,0,.10);
    --ink:#101828;
    --muted: rgba(16,24,40,.68);
    --shadow: 0 18px 50px rgba(16,24,40,.12);
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;
    background: radial-gradient(1200px 800px at 20% 10%, rgba(124,240,255,.15), transparent 60%),
                radial-gradient(1200px 800px at 80% 20%, rgba(124,160,255,.14), transparent 60%),
                var(--bg);
    color:var(--ink);
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
    overflow:hidden;
  }
  .app{
    height:100%;
    display:grid;
    grid-template-columns: 320px 1fr;
    gap: 12px;
    padding: 12px;
  }
  .card{
    background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
    border: 1px solid var(--stroke);
    border-radius: var(--radius2);
    box-shadow: var(--shadow);
    backdrop-filter: var(--glass);
    overflow:hidden;
    min-height:0;
  }
  .sidebar{display:flex; flex-direction:column; min-height:0;}
  .main{display:flex; flex-direction:column; min-height:0;}

  /* Topbar */
  .topbar{
    display:flex;
    align-items:center;
    gap:10px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
    background: rgba(0,0,0,.08);
  }
  [data-theme="light"] .topbar{ background: rgba(255,255,255,.55); }
  .brand{
    display:flex; align-items:center; gap:10px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
  }
  .logo{
    width:34px; height:34px; border-radius:12px;
    background: radial-gradient(circle at 30% 30%, var(--accent), rgba(124,240,255,.0) 55%),
                radial-gradient(circle at 70% 70%, var(--accent2), rgba(124,160,255,.0) 55%),
                rgba(255,255,255,.06);
    border:1px solid var(--stroke);
    box-shadow: 0 12px 30px rgba(124,240,255,.14);
  }
  .brand h1{
    font-size:14px; margin:0; letter-spacing:.4px;
  }
  .brand p{margin:0; font-size:12px; color:var(--muted)}
  .btn{
    appearance:none;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.06);
    color:var(--ink);
    padding:8px 10px;
    border-radius: 12px;
    cursor:pointer;
    transition: transform .08s ease, background .15s ease, border-color .15s ease;
    user-select:none;
    white-space:nowrap;
  }
  .btn:hover{ background: rgba(255,255,255,.10); border-color: rgba(124,240,255,.28); }
  .btn:active{ transform: scale(.98); }
  .btn.primary{
    border-color: rgba(124,240,255,.35);
    background: linear-gradient(180deg, rgba(124,240,255,.18), rgba(124,160,255,.10));
  }
  .btn.danger{
    border-color: rgba(255,107,107,.35);
    background: linear-gradient(180deg, rgba(255,107,107,.16), rgba(255,107,107,.08));
  }
  .btn.ok{
    border-color: rgba(121,255,167,.35);
    background: linear-gradient(180deg, rgba(121,255,167,.14), rgba(121,255,167,.07));
  }
  .icon{
    width:18px;height:18px;display:inline-grid;place-items:center;
    font-weight:700; opacity:.9;
  }

  /* Address */
  .addr{
    flex:1;
    display:flex;
    gap:10px;
    align-items:center;
    min-width:0;
  }
  .addr input{
    width:100%;
    min-width:0;
    padding:10px 12px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.14);
    color:var(--ink);
    outline:none;
  }
  [data-theme="light"] .addr input{ background: rgba(255,255,255,.75); }
  .hint{
    font-size:12px;
    color:var(--muted);
    padding: 0 12px 10px;
  }

  /* Tabs */
  .tabs{
    display:flex;
    gap:8px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
    overflow:auto;
  }
  .tab{
    display:flex; align-items:center; gap:8px;
    padding:8px 10px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.06);
    cursor:pointer;
    min-width: 160px;
    max-width: 260px;
    flex: 0 0 auto;
  }
  .tab.active{
    border-color: rgba(124,240,255,.45);
    background: linear-gradient(180deg, rgba(124,240,255,.16), rgba(124,160,255,.10));
  }
  .tab .title{
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
    font-size:13px;
    flex:1;
  }
  .pill{
    font-size:11px;
    color:var(--muted);
    border:1px solid var(--stroke);
    padding:2px 8px;
    border-radius:999px;
    background: rgba(0,0,0,.10);
  }
  [data-theme="light"] .pill{ background: rgba(255,255,255,.6); }
  .x{
    width:24px;height:24px; border-radius:10px;
    display:grid; place-items:center;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.10);
    opacity:.9;
  }
  .x:hover{ border-color: rgba(255,107,107,.5); }
  [data-theme="light"] .x{ background: rgba(255,255,255,.6); }

  /* Viewport */
  .viewport{
    position:relative;
    flex:1;
    min-height:0;
    background: rgba(0,0,0,.10);
  }
  [data-theme="light"] .viewport{ background: rgba(0,0,0,.03); }
  .frame{
    position:absolute; inset:0;
    width:100%; height:100%;
    border:0;
    background: transparent;
  }
  .overlay{
    position:absolute; inset: 14px;
    border-radius: 18px;
    border:1px dashed rgba(124,240,255,.35);
    display:none;
    place-items:center;
    text-align:center;
    padding:18px;
    background: rgba(0,0,0,.35);
    backdrop-filter: blur(10px);
  }
  [data-theme="light"] .overlay{ background: rgba(255,255,255,.78); }
  .overlay.show{ display:grid; }
  .overlay h2{margin:0 0 8px; font-size:16px;}
  .overlay p{margin:0 0 12px; color:var(--muted); font-size:13px;}
  .overlay .row{display:flex; gap:10px; flex-wrap:wrap; justify-content:center}

  /* Sidebar content */
  .section{
    padding:12px;
    border-top:1px solid var(--stroke);
    min-height:0;
    overflow:auto;
  }
  .section h3{
    margin:0 0 10px;
    font-size:12px;
    color:var(--muted);
    letter-spacing:.18em;
  }
  .list{
    display:flex;
    flex-direction:column;
    gap:8px;
  }
  .item{
    display:flex;
    gap:10px;
    align-items:center;
    padding:10px 10px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.05);
    cursor:pointer;
  }
  .item:hover{ border-color: rgba(124,240,255,.28); background: rgba(255,255,255,.08); }
  .item .meta{flex:1; min-width:0}
  .item .meta .t{
    font-size:13px;
    overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
  }
  .item .meta .s{
    font-size:12px; color:var(--muted);
    overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
  }
  .tag{
    font-size:11px;
    padding:2px 8px;
    border-radius: 999px;
    border:1px solid var(--stroke);
    color: var(--muted);
  }

  .footerbar{
    padding:10px 12px;
    border-top:1px solid var(--stroke);
    font-size:12px;
    color:var(--muted);
    display:flex;
    gap:12px;
    align-items:center;
    justify-content:space-between;
  }
  .kbd{
    border:1px solid var(--stroke);
    border-bottom-width:2px;
    padding:2px 6px;
    border-radius:8px;
    background: rgba(0,0,0,.10);
    font-size:11px;
    color:var(--muted);
    white-space:nowrap;
  }
  [data-theme="light"] .kbd{ background: rgba(255,255,255,.6); }

  .row{
    display:flex; gap:8px; flex-wrap:wrap;
  }
  .mini{
    font-size:12px;
    padding:6px 8px;
    border-radius: 12px;
  }

  .toast{
    position:fixed;
    right:14px; bottom:14px;
    padding:10px 12px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.50);
    backdrop-filter: blur(10px);
    color:var(--ink);
    box-shadow: var(--shadow);
    transform: translateY(10px);
    opacity:0;
    transition: .22s ease;
    pointer-events:none;
    max-width: min(420px, calc(100vw - 28px));
  }
  [data-theme="light"] .toast{ background: rgba(255,255,255,.86); }
  .toast.show{ transform: translateY(0); opacity:1; }
  .toast .small{ font-size:12px; color:var(--muted); margin-top:2px; }

  @media (max-width: 980px){
    .app{ grid-template-columns: 1fr; }
    .sidebar{ display:none; }
  }
</style>
</head>
<body data-theme="dark">
  <div class="app">
    <!-- Sidebar -->
    <aside class="card sidebar">
      <div class="brand">
        <div class="logo" aria-hidden="true"></div>
        <div>
          <h1>Voogle</h1>
          <p>Mini Browser (1-file)</p>
        </div>
      </div>

      <div class="section" style="border-top:none">
        <div class="row">
          <button class="btn mini primary" id="btnNewTab"><span class="icon">+</span>新規タブ</button>
          <button class="btn mini" id="btnToggleTheme"><span class="icon">☾</span>テーマ</button>
          <button class="btn mini" id="btnExport"><span class="icon">⤓</span>データ出力</button>
          <label class="btn mini" style="display:inline-flex; align-items:center; gap:8px; cursor:pointer;">
            <span class="icon">⤒</span>データ取込
            <input id="importFile" type="file" accept="application/json" style="display:none" />
          </label>
        </div>
        <div class="hint">※多くの外部サイトは埋め込み禁止。開けない時は「新しいタブで開く」。</div>
      </div>

      <div class="section">
        <h3>クイック</h3>
        <div class="list" id="quickList"></div>
      </div>

      <div class="section">
        <h3>ブックマーク</h3>
        <div class="list" id="bmList"></div>
      </div>

      <div class="section">
        <h3>履歴(最新20件)</h3>
        <div class="list" id="histList"></div>
      </div>

      <div class="footerbar">
        <div class="row" style="gap:6px">
          <span class="kbd">Ctrl</span>+<span class="kbd">L</span> アドレス
          <span class="kbd">Ctrl</span>+<span class="kbd">T</span> 新規
          <span class="kbd">Ctrl</span>+<span class="kbd">W</span> 閉じる
        </div>
        <span id="statusText">Ready</span>
      </div>
    </aside>

    <!-- Main -->
    <main class="card main">
      <div class="topbar">
        <button class="btn" id="btnBack" title="戻る"><span class="icon">←</span></button>
        <button class="btn" id="btnForward" title="進む"><span class="icon">→</span></button>
        <button class="btn" id="btnReload" title="更新"><span class="icon">↻</span></button>

        <div class="addr">
          <input id="addrInput" placeholder="URL または 検索ワード(例: https://example.com / openai)" autocomplete="off" />
        </div>

        <button class="btn primary" id="btnGo" title="移動"><span class="icon">⏎</span></button>
        <button class="btn" id="btnBookmark" title="ブックマーク"><span class="icon">☆</span></button>
        <button class="btn" id="btnOpenExternal" title="新しいタブで開く"><span class="icon">↗</span></button>
        <button class="btn" id="btnPip" title="PiP(対応サイトのみ)"><span class="icon">▣</span></button>
      </div>

      <div class="tabs" id="tabs"></div>

      <div class="viewport">
        <iframe id="frame" class="frame" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>

        <div class="overlay" id="overlay">
          <div>
            <h2>このページは埋め込みを拒否してる</h2>
            <p>
              たいていはサイト側のセキュリティ(X-Frame-Options / CSP)です。<br>
              下のボタンで外部タブとして開け。
            </p>
            <div class="row">
              <button class="btn ok" id="overlayOpenExternal"><span class="icon">↗</span>新しいタブで開く</button>
              <button class="btn" id="overlayTrySearch"><span class="icon">⌕</span>検索で開く</button>
              <button class="btn danger" id="overlayClose"><span class="icon">×</span>閉じる</button>
            </div>
          </div>
        </div>
      </div>

      <div class="footerbar">
        <div class="row" style="gap:10px; align-items:center">
          <span class="tag" id="originTag">—</span>
          <span class="tag" id="secureTag">—</span>
          <span class="tag" id="embedTag">—</span>
        </div>
        <div class="row" style="gap:8px">
          <button class="btn mini" id="btnHome">ホーム</button>
          <button class="btn mini" id="btnClearHistory">履歴クリア</button>
          <button class="btn mini danger" id="btnResetAll">全リセット</button>
        </div>
      </div>
    </main>
  </div>

  <div class="toast" id="toast"></div>

<script>
(() => {
  // =========================
  // Utilities
  // =========================
  const $ = (sel) => document.querySelector(sel);
  const escapeHTML = (s) => (s ?? "").toString()
    .replaceAll("&","&amp;").replaceAll("<","&lt;")
    .replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#039;");
  const nowISO = () => new Date().toISOString();
  const isUrlLike = (text) => {
    const t = (text || "").trim();
    if (!t) return false;
    if (/^https?:\/\//i.test(t)) return true;
    if (/^[a-z0-9.-]+\.[a-z]{2,}([\/?#].*)?$/i.test(t)) return true;
    if (/^localhost(:\d+)?(\/.*)?$/i.test(t)) return true;
    if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/.*)?$/.test(t)) return true;
    return false;
  };
  const normalizeToUrl = (text) => {
    let t = (text || "").trim();
    if (!t) return "";
    if (/^https?:\/\//i.test(t)) return t;
    if (isUrlLike(t)) return "https://" + t;
    return "";
  };
  const buildSearchUrl = (q) => "https://www.google.com/search?q=" + encodeURIComponent(q);

  const toast = (title, detail="") => {
    const el = $("#toast");
    el.innerHTML = `<div><b>${escapeHTML(title)}</b></div>${detail ? `<div class="small">${escapeHTML(detail)}</div>` : ""}`;
    el.classList.add("show");
    clearTimeout(toast._t);
    toast._t = setTimeout(() => el.classList.remove("show"), 2400);
  };

  const setStatus = (s) => { $("#statusText").textContent = s; };

  // =========================
  // Storage
  // =========================
  const KEY = "voogle.v1";
  const defaultState = () => ({
    theme: "dark",
    tabs: [
      { id: crypto.randomUUID(), title: "Home", url: "about:home", history: ["about:home"], hIndex: 0, createdAt: nowISO() }
    ],
    activeTabId: null,
    bookmarks: [
      { title:"OpenAI", url:"https://openai.com", addedAt: nowISO() },
      { title:"Wikipedia", url:"https://ja.wikipedia.org", addedAt: nowISO() },
      { title:"GitHub", url:"https://github.com", addedAt: nowISO() },
      { title:"MDN", url:"https://developer.mozilla.org/ja/", addedAt: nowISO() },
      { title:"YouTube", url:"https://www.youtube.com", addedAt: nowISO() },
    ],
    history: [],
    quick: [
      { title:"ニュース", url:"https://news.google.com/?hl=ja&gl=JP&ceid=JP:ja" },
      { title:"X", url:"https://x.com" },
      { title:"Reddit", url:"https://www.reddit.com" },
      { title:"Qiita", url:"https://qiita.com" },
      { title:"Zenn", url:"https://zenn.dev" },
      { title:"Google", url:"https://www.google.com" },
    ],
    settings: {
      homeUrl: "about:home",
      maxHistory: 200,
      sidebarHistoryView: 20
    }
  });

  const loadState = () => {
    try{
      const raw = localStorage.getItem(KEY);
      if(!raw) return defaultState();
      const s = JSON.parse(raw);
      // minimal migrate
      if(!s.tabs?.length) return defaultState();
      return s;
    }catch(e){
      console.warn(e);
      return defaultState();
    }
  };
  const saveState = () => localStorage.setItem(KEY, JSON.stringify(state));

  let state = loadState();

  // =========================
  // Tabs
  // =========================
  const getActiveTab = () => state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
  const setActive = (id) => {
    state.activeTabId = id;
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
  };

  const newTab = (url = "about:home", title = "New Tab") => {
    const tab = {
      id: crypto.randomUUID(),
      title,
      url,
      history: [url],
      hIndex: 0,
      createdAt: nowISO()
    };
    state.tabs.push(tab);
    state.activeTabId = tab.id;
    saveState();
    renderAll();
    loadTabToViewport(tab);
    toast("新規タブ", title);
  };

  const closeTab = (id) => {
    if(state.tabs.length <= 1){
      toast("これ以上閉じれない", "最低1タブは残る");
      return;
    }
    const idx = state.tabs.findIndex(t => t.id === id);
    if(idx < 0) return;
    const wasActive = state.activeTabId === id;
    const closed = state.tabs[idx];
    state.tabs.splice(idx,1);
    if(wasActive){
      const fallback = state.tabs[Math.max(0, idx-1)];
      state.activeTabId = fallback.id;
    }
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
    toast("タブを閉じた", closed.title);
  };

  const setTabTitle = (tab, title) => {
    tab.title = (title || "Untitled").slice(0, 60);
    saveState();
    renderTabs();
  };

  const pushHistory = (tab, url) => {
    if(tab.history[tab.hIndex] === url) return;
    tab.history = tab.history.slice(0, tab.hIndex + 1);
    tab.history.push(url);
    tab.hIndex = tab.history.length - 1;
  };

  // =========================
  // History + Bookmarks
  // =========================
  const addGlobalHistory = (url, title="") => {
    if(!url || url === "about:home") return;
    state.history.unshift({ url, title, at: nowISO() });
    // de-dupe
    const seen = new Set();
    state.history = state.history.filter(h => {
      const k = h.url;
      if(seen.has(k)) return false;
      seen.add(k);
      return true;
    });
    state.history = state.history.slice(0, state.settings.maxHistory || 200);
    saveState();
    renderSidebar();
  };

  const isBookmarked = (url) => state.bookmarks.some(b => b.url === url);
  const toggleBookmark = () => {
    const tab = getActiveTab();
    const url = tab.url;
    if(!url || url === "about:home") return toast("ホームは登録しない");
    if(isBookmarked(url)){
      state.bookmarks = state.bookmarks.filter(b => b.url !== url);
      saveState();
      renderSidebar();
      toast("ブックマーク削除", url);
    }else{
      state.bookmarks.unshift({ title: tab.title || url, url, addedAt: nowISO() });
      saveState();
      renderSidebar();
      toast("ブックマーク追加", tab.title || url);
    }
    renderIndicators();
  };

  // =========================
  // Viewport Loader
  // =========================
  const frame = $("#frame");
  const overlay = $("#overlay");

  let embedBlockedTimer = null;

  const setOverlay = (show) => {
    overlay.classList.toggle("show", !!show);
  };

  const updateAddressBar = (tab) => {
    $("#addrInput").value = tab.url === "about:home" ? "" : tab.url;
  };

  const renderIndicators = () => {
    const tab = getActiveTab();
    const url = tab.url || "";
    const originTag = $("#originTag");
    const secureTag = $("#secureTag");
    const embedTag = $("#embedTag");

    let origin = "—";
    try{
      if(url.startsWith("about:")) origin = "about";
      else origin = (new URL(url)).hostname;
    }catch(e){ origin = "—"; }

    const secure = url.startsWith("https://") ? "HTTPS" : (url.startsWith("http://") ? "HTTP" : "—");
    originTag.textContent = origin;
    secureTag.textContent = secure;

    const bm = isBookmarked(url) ? "Bookmarked" : "Not bookmarked";
    embedTag.textContent = bm;
  };

  const loadTabToViewport = (tab) => {
    setOverlay(false);
    clearTimeout(embedBlockedTimer);
    renderIndicators();
    updateAddressBar(tab);

    const url = tab.url;

    if(url === "about:home"){
      frame.removeAttribute("src");
      frame.srcdoc = homeHTML();
      setStatus("Home");
      return;
    }

    frame.removeAttribute("srcdoc");
    frame.src = url;

    setStatus("Loading…");

    // "埋め込みブロック" は確実に検知できないが、
    // 一定時間で表示されなければ overlay を出して逃げ道を用意する。
    embedBlockedTimer = setTimeout(() => {
      // about:blank だったり、何も表示されないケースを想定
      // ここは「保険」なので強制表示ではなく、状況を見て出す
      // → タブのURLが外部なら基本出す
      if(getActiveTab().url === url){
        setOverlay(true);
        setStatus("Embed blocked (maybe)");
      }
    }, 1400);
  };

  frame.addEventListener("load", () => {
    clearTimeout(embedBlockedTimer);
    const tab = getActiveTab();
    // タイトルの推定は、クロスオリジンだと取れないのでURLから作る
    if(tab.url.startsWith("about:")){
      setStatus("Ready");
      return;
    }
    setOverlay(false);
    setStatus("Ready");
    addGlobalHistory(tab.url, tab.title);

    // タイトル推定
    let title = tab.title;
    try{
      const u = new URL(tab.url);
      title = u.hostname;
      if(u.pathname && u.pathname !== "/") title += u.pathname.slice(0, 14) + (u.pathname.length > 14 ? "…" : "");
    }catch(e){}
    setTabTitle(tab, title);
    renderIndicators();
  });

  frame.addEventListener("error", () => {
    clearTimeout(embedBlockedTimer);
    setOverlay(true);
    setStatus("Load error");
  });

  const navigate = (input) => {
    const tab = getActiveTab();
    const raw = (input ?? $("#addrInput").value).trim();
    if(!raw){
      tab.url = "about:home";
      pushHistory(tab, tab.url);
      saveState();
      loadTabToViewport(tab);
      renderTabs();
      return;
    }

    const url = normalizeToUrl(raw) || buildSearchUrl(raw);
    tab.url = url;
    pushHistory(tab, url);
    saveState();
    renderTabs();
    loadTabToViewport(tab);
    toast("移動", url);
  };

  const back = () => {
    const tab = getActiveTab();
    if(tab.hIndex <= 0) return toast("戻れない");
    tab.hIndex -= 1;
    tab.url = tab.history[tab.hIndex];
    saveState();
    renderTabs();
    loadTabToViewport(tab);
  };

  const forward = () => {
    const tab = getActiveTab();
    if(tab.hIndex >= tab.history.length - 1) return toast("進めない");
    tab.hIndex += 1;
    tab.url = tab.history[tab.hIndex];
    saveState();
    renderTabs();
    loadTabToViewport(tab);
  };

  const reload = () => {
    const tab = getActiveTab();
    if(tab.url === "about:home"){
      loadTabToViewport(tab);
      return;
    }
    try{
      frame.contentWindow.location.reload();
    }catch(e){
      // クロスオリジンは reload 制限があるので src 再設定
      frame.src = tab.url;
    }
    setStatus("Reloading…");
  };

  const openExternal = () => {
    const tab = getActiveTab();
    const url = tab.url === "about:home" ? buildSearchUrl($("#addrInput").value.trim() || "home") : tab.url;
    window.open(url, "_blank", "noopener,noreferrer");
    toast("外部で開いた", url);
  };

  const tryPip = async () => {
    try{
      const doc = frame.contentDocument;
      if(!doc) throw new Error("Cross-origin");
      const video = doc.querySelector("video");
      if(!video) return toast("動画が見つからない", "このページに <video> がない");
      if(document.pictureInPictureElement) await document.exitPictureInPicture();
      await video.requestPictureInPicture();
      toast("PiP", "Picture-in-Picture");
    }catch(e){
      toast("PiP不可", "多くの外部サイトは制限がある");
    }
  };

  // =========================
  // UI Render
  // =========================
  const renderTabs = () => {
    const el = $("#tabs");
    const activeId = state.activeTabId || state.tabs[0]?.id;
    if(!state.activeTabId) state.activeTabId = activeId;

    el.innerHTML = state.tabs.map(t => {
      const active = t.id === activeId ? "active" : "";
      const pill = t.url === "about:home" ? "HOME" : (t.url.startsWith("https://") ? "HTTPS" : (t.url.startsWith("http://") ? "HTTP" : "—"));
      return `
        <div class="tab ${active}" data-tab="${t.id}">
          <div class="title">${escapeHTML(t.title || "Untitled")}</div>
          <span class="pill">${escapeHTML(pill)}</span>
          <div class="x" title="閉じる" data-close="${t.id}">×</div>
        </div>
      `;
    }).join("");

    el.querySelectorAll(".tab").forEach(tabEl => {
      tabEl.addEventListener("click", (ev) => {
        const closeId = ev.target?.getAttribute?.("data-close");
        if(closeId){
          ev.stopPropagation();
          closeTab(closeId);
          return;
        }
        const id = tabEl.getAttribute("data-tab");
        setActive(id);
      });
    });
  };

  const renderSidebar = () => {
    // Quick
    const q = $("#quickList");
    q.innerHTML = state.quick.map(x => `
      <div class="item" data-url="${escapeHTML(x.url)}">
        <div class="icon">⚡</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title)}</div>
          <div class="s">${escapeHTML(x.url)}</div>
        </div>
        <span class="tag">OPEN</span>
      </div>
    `).join("");

    // Bookmarks
    const b = $("#bmList");
    b.innerHTML = (state.bookmarks.length ? state.bookmarks : [{title:"(なし)", url:""}]).map(x => `
      <div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
        <div class="icon">☆</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title)}</div>
          <div class="s">${escapeHTML(x.url)}</div>
        </div>
        ${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
      </div>
    `).join("");

    // History
    const h = $("#histList");
    const max = state.settings.sidebarHistoryView || 20;
    const hist = state.history.slice(0, max);
    h.innerHTML = (hist.length ? hist : [{title:"(なし)", url:""}]).map(x => `
      <div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
        <div class="icon">⟲</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title || x.url || "(なし)")}</div>
          <div class="s">${escapeHTML(x.url || "")}</div>
        </div>
        ${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
      </div>
    `).join("");

    // Handlers
    const bindOpen = (root) => {
      root.querySelectorAll(".item").forEach(it => {
        it.addEventListener("click", () => {
          const url = it.getAttribute("data-url");
          if(!url) return;
          navigate(url);
        });
      });
    };
    bindOpen(q); bindOpen(b); bindOpen(h);
  };

  const renderTheme = () => {
    document.body.setAttribute("data-theme", state.theme || "dark");
    $("#btnToggleTheme").innerHTML = state.theme === "dark"
      ? `<span class="icon">☾</span>テーマ`
      : `<span class="icon">☀</span>テーマ`;
  };

  const renderAll = () => {
    renderTheme();
    renderTabs();
    renderSidebar();
    renderIndicators();
  };

  // =========================
  // Home page (srcdoc)
  // =========================
  const homeHTML = () => {
    const quick = state.quick.slice(0, 6).map(x => `
      <a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
        <div class="t">${escapeHTML(x.title)}</div>
        <div class="s">${escapeHTML(x.url)}</div>
      </a>
    `).join("");

    const bm = state.bookmarks.slice(0, 6).map(x => `
      <a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
        <div class="t">☆ ${escapeHTML(x.title)}</div>
        <div class="s">${escapeHTML(x.url)}</div>
      </a>
    `).join("");

    return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Home</title>
<style>
  :root{ color-scheme: dark; }
  body{
    margin:0;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
    background: radial-gradient(1000px 600px at 20% 20%, rgba(124,240,255,.18), transparent 60%),
                radial-gradient(1000px 600px at 80% 20%, rgba(124,160,255,.16), transparent 60%),
                #0b0f14;
    color:#eaf0ff;
  }
  .wrap{ padding: 18px; }
  h1{ font-size:18px; margin:0 0 8px; }
  p{ margin:0 0 14px; opacity:.75; font-size:13px; }
  .grid{
    display:grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap:10px;
  }
  .card{
    display:block;
    padding:12px 12px;
    border-radius: 16px;
    border:1px solid rgba(255,255,255,.10);
    background: rgba(255,255,255,.06);
    text-decoration:none;
    color:inherit;
  }
  .card:hover{ border-color: rgba(124,240,255,.35); background: rgba(255,255,255,.09); }
  .t{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .s{ font-size:12px; opacity:.70; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:4px; }
  .row{ display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
  .pill{
    border:1px solid rgba(255,255,255,.10);
    padding:6px 10px;
    border-radius: 999px;
    background: rgba(255,255,255,.06);
    font-size:12px;
    opacity:.85;
  }
</style>
</head>
<body>
  <div class="wrap">
    <h1>Voogle Home</h1>
    <p>アドレスバーにURLか検索ワードを入れて Enter。埋め込み不可サイトは外部タブで開く。</p>

    <div class="row">
      <span class="pill">Ctrl+L: アドレス</span>
      <span class="pill">Ctrl+T: 新規タブ</span>
      <span class="pill">Ctrl+W: タブ閉じる</span>
      <span class="pill">Ctrl+R: 更新</span>
    </div>

    <h1 style="margin-top:18px">Quick</h1>
    <div class="grid">${quick}</div>

    <h1 style="margin-top:18px">Bookmarks</h1>
    <div class="grid">${bm}</div>
  </div>
</body>
</html>`;
  };

  // =========================
  // Export / Import
  // =========================
  const exportData = () => {
    const data = JSON.stringify(state, null, 2);
    const blob = new Blob([data], {type:"application/json"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "voogle-data.json";
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
    toast("出力した", "voogle-data.json");
  };

  const importData = async (file) => {
    try{
      const text = await file.text();
      const obj = JSON.parse(text);
      if(!obj || !obj.tabs || !Array.isArray(obj.tabs)) throw new Error("Invalid");
      state = obj;
      saveState();
      renderAll();
      loadTabToViewport(getActiveTab());
      toast("取込完了", "データを復元した");
    }catch(e){
      toast("取込失敗", "JSONが壊れてるか形式が違う");
    }
  };

  const resetAll = () => {
    if(!confirm("全データを初期化する?")) return;
    state = defaultState();
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
    toast("初期化した");
  };

  // =========================
  // Bindings
  // =========================
  $("#btnGo").addEventListener("click", () => navigate());
  $("#addrInput").addEventListener("keydown", (e) => {
    if(e.key === "Enter") navigate();
  });

  $("#btnBack").addEventListener("click", back);
  $("#btnForward").addEventListener("click", forward);
  $("#btnReload").addEventListener("click", reload);
  $("#btnBookmark").addEventListener("click", toggleBookmark);
  $("#btnOpenExternal").addEventListener("click", openExternal);
  $("#btnPip").addEventListener("click", tryPip);
  $("#btnNewTab").addEventListener("click", () => newTab("about:home", "Home"));

  $("#btnToggleTheme").addEventListener("click", () => {
    state.theme = (state.theme === "dark") ? "light" : "dark";
    saveState();
    renderTheme();
    toast("テーマ", state.theme);
  });

  $("#btnExport").addEventListener("click", exportData);
  $("#importFile").addEventListener("change", (e) => {
    const f = e.target.files?.[0];
    if(f) importData(f);
    e.target.value = "";
  });

  $("#btnHome").addEventListener("click", () => navigate("about:home"));
  $("#btnClearHistory").addEventListener("click", () => {
    state.history = [];
    saveState();
    renderSidebar();
    toast("履歴クリア");
  });
  $("#btnResetAll").addEventListener("click", resetAll);

  // Overlay buttons
  $("#overlayOpenExternal").addEventListener("click", openExternal);
  $("#overlayTrySearch").addEventListener("click", () => {
    const tab = getActiveTab();
    const q = tab.url && tab.url !== "about:home" ? tab.url : ($("#addrInput").value.trim() || "home");
    newTab(buildSearchUrl(q), "Search");
  });
  $("#overlayClose").addEventListener("click", () => {
    setOverlay(false);
    toast("閉じた");
  });

  // Keyboard shortcuts
  window.addEventListener("keydown", (e) => {
    const ctrl = e.ctrlKey || e.metaKey;
    if(ctrl && e.key.toLowerCase() === "l"){ e.preventDefault(); $("#addrInput").focus(); $("#addrInput").select(); }
    if(ctrl && e.key.toLowerCase() === "t"){ e.preventDefault(); newTab("about:home", "Home"); }
    if(ctrl && e.key.toLowerCase() === "w"){ e.preventDefault(); closeTab(getActiveTab().id); }
    if(ctrl && e.key.toLowerCase() === "r"){ e.preventDefault(); reload(); }
    if(ctrl && e.key === "Enter"){ e.preventDefault(); openExternal(); }
  });

  // Init
  if(!state.activeTabId) state.activeTabId = state.tabs[0].id;
  renderAll();
  loadTabToViewport(getActiveTab());
})();
</script>
</body>
</html>

HTMLの基礎

HTMLってなに?

  • Webページの骨組みを作る言語(見出し・段落・画像・リンクなどの構造)。
  • 見た目はCSS、動きはJSが担当。HTMLは“意味と構造”。

まずは雛形(コピペOK)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>はじめてのHTML</title>
  <meta name="description" content="このページの説明文" />
</head>
<body>
  <h1>こんにちは!</h1>
  <p>これは最小構成のHTML5ページです。</p>
</body>
</html>

よく使う要素(超基本)

  • 見出し:<h1>~<h6>(ページに基本はh1は1つ)
  • 段落:<p>
  • リンク:<a href="https://example.com">リンク</a>
  • 画像:<img src="img.png" alt="画像の説明">(alt必須)
  • リスト:<ul><li>…</li></ul> / <ol>…</ol>
  • 強調:<strong>(重要) / <em>(強調)
  • 区切り:<br>(改行は最小限)、<hr>(区切り線)
  • まとまり:<div>(汎用ブロック)、<span>(汎用インライン)

セマンティック要素(構造をわかりやすく)

  • header(ヘッダー)
  • nav(ナビ)
  • main(主内容は1ページ1つ)
  • section(章)
  • article(単体で完結する記事)
  • aside(補足)
  • footer(フッター)

属性のキホン

  • id(一意な識別子)/class(グループ化)
  • href(リンク先)/src(画像・スクリプト元)
  • alt(画像代替文)/title(補足ヒント)
  • target="_blank"はrel="noopener noreferrer"とセットで

フォーム最小例

<form action="/search" method="get">
  <label for="q">検索:</label>
  <input id="q" name="q" type="search" required>
  <button type="submit">送信</button>
</form>

テーブル最小例(表)

<table>
  <thead><tr><th>商品</th><th>価格</th></tr></thead>
  <tbody>
    <tr><td>りんご</td><td>120</td></tr>
    <tr><td>みかん</td><td>100</td></tr>
  </tbody>
</table>

CSS / JS の読み込み

<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
  • deferはHTML解析後に実行(推奨)。

ちょっとだけ“実践的”なサンプル

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ミニサイト</title>
  <style>
    body { font-family: system-ui, sans-serif; line-height: 1.7; margin: 0; }
    header, footer { padding: 16px; background: #f5f5f5; }
    nav a { margin-right: 12px; }
    main { max-width: 920px; margin: 24px auto; padding: 0 16px; }
    img { max-width: 100%; height: auto; }
    .card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
  </style>
</head>
<body>
  <header>
    <h1>ミニサイト</h1>
    <nav>
      <a href="#about">概要</a>
      <a href="#gallery">ギャラリー</a>
      <a href="#contact">お問い合わせ</a>
    </nav>
  </header>

  <main id="content">
    <section id="about">
      <h2>概要</h2>
      <p>これはHTMLの基本で作ったミニページです。</p>
    </section>

    <section id="gallery">
      <h2>ギャラリー</h2>
      <div class="card">
        <img src="sample.jpg" alt="サンプル画像">
        <p>レスポンシブに画像が縮みます。</p>
      </div>
      <ul>
        <li>箇条書き1</li>
        <li>箇条書き2</li>
      </ul>
    </section>

    <section id="contact">
      <h2>お問い合わせ</h2>
      <form>
        <label for="name">お名前</label><br>
        <input id="name" name="name" required><br><br>
        <label for="msg">メッセージ</label><br>
        <textarea id="msg" name="msg" rows="4"></textarea><br><br>
        <button type="submit">送信</button>
      </form>
    </section>
  </main>

  <footer>
    <small>&copy; 2025 MiniSite</small>
  </footer>

  <script>
    // ごく簡単なJS:ナビをクリックしたらスムーズスクロール
    document.querySelectorAll('nav a').forEach(a => {
      a.addEventListener('click', e => {
        const id = a.getAttribute('href');
        if (id.startsWith('#')) {
          e.preventDefault();
          document.querySelector(id)?.scrollIntoView({ behavior: 'smooth' });
        }
      });
    });
  </script>
</body>
</html>

初心者がつまずきやすいポイント

  • 文字化け→<meta charset="utf-8">を必ず入れる。
  • スマホで拡大縮小が変→<meta name="viewport" …>を入れる。
  • 画像が大きすぎる→CSSでimg { max-width: 100%; height: auto; }
  • 見出し乱用→h1はページの主題に1回、階層は順序を守る。
  • altなし→スクリーンリーダー/SEO的にマイナス。必ず書く。

もっと深掘り(フォームのバリデーション、SEO、アクセシビリティ、Flex/Gridレイアウト、コンポーネント化など)もまとめられます。どこから強化したい?(例:フォームをしっかり、レイアウトを学ぶ、CSS設計、JS連携 など)

QuestFoundry

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Quest Foundry | 世界観からクエスト自動設計</title>
  <meta name="description" content="世界観のキーワードからNPC・アイテム・場所・クエストを一括生成。JSON/CSVエクスポート、依存関係、難易度バランス、シード固定対応。" />
  <!-- Tailwind CDN (Node不要) -->
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: { sans: ["Noto Sans JP", "ui-sans-serif", "system-ui"] },
          colors: { brand: { 50: '#eef2ff', 100:'#e0e7ff', 200:'#c7d2fe', 300:'#a5b4fc', 400:'#818cf8', 500:'#6366f1', 600:'#4f46e5', 700:'#4338ca', 800:'#3730a3', 900:'#312e81'} }
        }
      }
    };
  </script>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
  <style>
    html, body { height: 100%; }
    .glass { backdrop-filter: blur(10px); background: rgba(255,255,255,0.7); }
    .prose pre { white-space: pre-wrap; word-break: break-word; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    .card { @apply rounded-2xl shadow-lg p-5 bg-white; }
      .prose h1{font-size:1.5rem;line-height:1.3;margin:0 0 .6rem;font-weight:800}
    .prose h2{font-size:1.2rem;line-height:1.35;margin:1.2rem 0 .4rem;font-weight:700;border-left:4px solid #6366f1;padding-left:.6rem}
    .prose h3{font-size:1rem;line-height:1.4;margin:1rem 0 .3rem;font-weight:700}
    .prose ul{list-style:disc;padding-left:1.25rem;margin:.4rem 0 .8rem}
    .prose li{margin:.2rem 0}
    .badge{display:inline-block;font-size:.72rem;line-height:1;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;border-radius:.5rem;padding:.15rem .45rem;margin-right:.25rem}
    details.quest{border:1px solid #e5e7eb;border-radius:.75rem;padding:.6rem .8rem;margin:.5rem 0;background:#fff}
    details.quest > summary{cursor:pointer;list-style:none}
    details.quest > summary::-webkit-details-marker{display:none}
    .kv{display:inline-grid;grid-template-columns:auto auto;gap:.2rem .6rem;align-items:center}
  </style>
</head>
<body class="min-h-screen bg-gradient-to-br from-brand-50 to-white text-slate-800">
  <header class="sticky top-0 z-40 border-b bg-white/80 backdrop-blur">
    <div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-4">
      <div class="text-2xl font-black tracking-tight"><span class="text-brand-700">Quest</span> Foundry</div>
      <div class="text-xs text-slate-500">世界観→NPC/アイテム/場所/クエストを自動生成(JSON/CSV出力可)</div>
      <div class="ml-auto flex items-center gap-2">
        <button id="btnSave" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">保存</button>
        <button id="btnLoad" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">読込</button>
        <button id="btnPrint" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">印刷/PDF</button>
      </div>
    </div>
  </header>

  <main class="mx-auto max-w-7xl px-4 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
    <!-- 左:設定フォーム -->
    <section class="lg:col-span-1 card">
      <h2 class="text-lg font-bold mb-4">ワールド設定</h2>
      <form id="worldForm" class="space-y-4">
        <div>
          <label class="block text-sm font-medium">世界名</label>
          <input id="worldName" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:アトラティア" />
        </div>
        <div>
          <label class="block text-sm font-medium">テーマ・キーワード(読点・スペース区切り)</label>
          <input id="themes" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:古代遺跡 砂漠 精霊 冒険者ギルド" />
        </div>
        <div class="grid grid-cols-2 gap-4">
          <div>
            <label class="block text-sm font-medium">難易度</label>
            <select id="difficulty" class="w-full mt-1 rounded-lg border px-3 py-2">
              <option value="easy">Easy</option>
              <option value="normal" selected>Normal</option>
              <option value="hard">Hard</option>
              <option value="epic">Epic</option>
            </select>
          </div>
          <div>
            <label class="block text-sm font-medium">クエスト数</label>
            <input id="questCount" type="number" min="1" max="30" value="8" class="w-full mt-1 rounded-lg border px-3 py-2" />
          </div>
        </div>
        <div class="grid grid-cols-2 gap-4">
          <div>
            <label class="block text-sm font-medium">シード(同じ結果を再現)</label>
            <input id="seed" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="未入力なら自動" />
          </div>
          <div class="flex items-end gap-2">
            <input id="lockSeed" type="checkbox" class="h-5 w-5" />
            <label for="lockSeed" class="text-sm">シード固定(再生成でも変化しない)</label>
          </div>
        </div>
        <div>
          <label class="block text-sm font-medium">トーン</label>
          <select id="tone" class="w-full mt-1 rounded-lg border px-3 py-2">
            <option value="classic" selected>古典ファンタジー</option>
            <option value="dark">ダーク</option>
            <option value="steampunk">スチームパンク</option>
            <option value="myth">神話/叙事詩</option>
            <option value="sci">サイファンタジー</option>
          </select>
        </div>
        <div class="flex flex-wrap gap-2 pt-2">
          <button id="btnGenerate" type="button" class="px-4 py-2 rounded-xl bg-brand-600 text-white hover:bg-brand-700">生成</button>
          <button id="btnRegenerate" type="button" class="px-4 py-2 rounded-xl bg-slate-800 text-white hover:bg-slate-900">再生成(同条件)</button>
          <button id="btnShuffleSeed" type="button" class="px-4 py-2 rounded-xl border">シード再抽選</button>
        </div>
      </form>
      <p class="text-xs text-slate-500 mt-4">※外部API不使用。テンプレート×確率モデルでローカル生成。ブラウザ上で完結。</p>
    </section>

    <!-- 中央:結果(テキスト) -->
    <section class="lg:col-span-2 card">
      <div class="flex items-center gap-2 mb-4">
        <h2 class="text-lg font-bold">生成結果</h2>
        <span id="meta" class="ml-auto text-xs text-slate-500"></span>
      </div>
      <div class="flex flex-wrap gap-2 mb-4">
        <button id="btnCopyText" class="px-3 py-2 rounded-lg border">テキストをコピー</button>
        <button id="btnDownloadJSON" class="px-3 py-2 rounded-lg border">JSONダウンロード</button>
        <button id="btnExportCSV" class="px-3 py-2 rounded-lg border">CSV書き出し</button>
        <button id="btnToggleJson" class="px-3 py-2 rounded-lg border">JSON表示切替</button>
      </div>
      <div id="outText" class="prose max-w-none text-sm leading-6"></div>
      <details id="jsonBlock" class="mt-4 hidden">
        <summary class="cursor-pointer select-none text-sm text-slate-600">JSON表示</summary>
        <pre id="outJSON" class="mono text-xs bg-slate-50 p-3 rounded-lg overflow-x-auto"></pre>
      </details>
    </section>

    <!-- 下:プレビュー(カードレイアウト) -->
    <section class="lg:col-span-3 card">
      <h2 class="text-lg font-bold mb-4">カードビュー</h2>
      <div class="grid md:grid-cols-3 gap-4" id="cards"></div>
    </section>
  </main>

  <footer class="py-8 text-center text-xs text-slate-500">
    &copy; 2025 Quest Foundry — Local-first Fantasy Content Generator
  </footer>

  <script>
    /* =========================
     *  乱数とユーティリティ
     * ========================= */
    function cyrb128(str){ let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){ let k=str.charCodeAt(i); h1=h2^(Math.imul(h1^k,597399067)); h2=h3^(Math.imul(h2^k,2869860233)); h3=h4^(Math.imul(h3^k,951274213)); h4=h1^(Math.imul(h4^k,2716044179)); } h1=Math.imul(h3^(h1>>>18),597399067); h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213); h4=Math.imul(h2^(h4>>>19),2716044179); let r=(h1^h2^h3^h4)>>>0; return r.toString(36); }
    function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^(t>>>15), t|1); t^=t+Math.imul(t^(t>>>7), t|61); return ((t^(t>>>14))>>>0)/4294967296; } }
    function rngFromSeed(seed){ let n=0; for(const ch of seed) n=(n*31 + ch.charCodeAt(0))>>>0; return mulberry32(n||1); }
    function choice(r, arr){ return arr[Math.floor(r()*arr.length)] }
    function pickN(r, arr, n){ const a=[...arr]; const out=[]; for(let i=0;i<n && a.length;i++){ out.push(a.splice(Math.floor(r()*a.length),1)[0]); } return out; }
    function cap(s){ return s.charAt(0).toUpperCase()+s.slice(1) }
    function id(prefix, i){ return `${prefix}-${String(i).padStart(3,'0')}` }

    function syllableName(r, tone){
      const syll = {
        classic:["an","ar","bel","ca","da","el","fa","gal","har","is","jor","kel","lir","mor","nel","or","pa","qua","rhi","sa","tor","ur","val","wen","xel","yor","zel"],
        dark:["mor","noir","gloam","umb","dol","grav","nek","var","zul","vex","drei","thar","khar","wyrm"],
        steampunk:["gear","steam","bolt","cog","brass","tink","pneu","copper","fuse","riv","spindle"],
        myth:["aeg","od","ish","ra","zeph","io","sol","lun","tyr","fre","eir","hel"],
        sci:["neo","ion","quant","cyber","astra","plasma","proto","omega","nova","phase","flux"]
      };
      const pool = (syll[tone]||[]).concat(syll.classic);
      const len = 2 + Math.floor(r()*2);
      let s=""; for(let i=0;i<len;i++) s+= choice(r,pool);
      return cap(s);
    }

    /* =========================
     *  テンプレ/語彙
     * ========================= */
    const LEX = {
      roles: ["ギルドマスター","考古学者","巡回騎士","密偵","占星術師","錬金術師","旅の商人","巫女","司書","鍛冶師","船乗り","薬師","狩人","吟遊詩人","修道士"],
      traits: ["勇敢","狡猾","博識","短気","誠実","猜疑心が強い","陽気","冷静","計算高い","臆病","義理堅い","野心家"],
      factions: ["碧星同盟","砂冠商会","螺旋教団","古図書騎士団","白霧旅団","錆鉄工房","風詠み集落","赤砂盗賊団"],
      biomes: ["砂漠","湿原","黒森","高地","沿岸","雪原","火山地帯","古代都市跡"],
      itemTypes: ["剣","短剣","槍","杖","弓","護符","指輪","書","設計図","薬","鉱石","布","レンズ","コイル"],
      rarities: ["Common","Uncommon","Rare","Epic","Legendary"],
      verbs: ["救出せよ","護衛せよ","探索せよ","奪還せよ","調査せよ","討伐せよ","修復せよ","封印せよ","交渉せよ","護送せよ","潜入せよ"],
      twists: ["依頼主は真犯人","実は時間制限あり","二重スパイがいる","偽物が混じっている","古き呪いが再発","天候異常が発生","儀式の日が前倒し"],
      rewardsExtra: ["評判+10","ギルドランク昇格","隠し店舗の解放","旅人の加護","快速移動の解放"]
    };

    const DIFF_MULT = { easy: 0.8, normal: 1.0, hard: 1.3, epic: 1.7 };

    /* =========================
     *  生成器
     * ========================= */
    function genFactions(r, themes){
      const count = Math.min(5, 2 + Math.floor(r()*4));
      return Array.from({length:count}, (_,i)=>({ id: id('F',i+1), name: `${choice(r,LEX.factions)}`, goal: `${choice(r,["遺物の独占","古文書の解読","交易路の掌握","禁術の復活","辺境防衛"])}`, vibe: choice(r,["協調的","中立","敵対的"]) }));
    }

    function genLocations(r, themes){
      const count = Math.min(8, 4 + Math.floor(r()*5));
      return Array.from({length:count}, (_,i)=>({ id: id('L',i+1), name: `${choice(r,LEX.biomes)}の${syllableName(r,'classic')}`, feature: choice(r,["崩れた門","封じ石","光る碑文","隠し水路","浮遊足場","古代機構"]) }));
    }

    function genNPCs(r, tone, factions){
      const count = Math.min(12, 6 + Math.floor(r()*6));
      return Array.from({length:count}, (_,i)=>{
        const fac = choice(r, factions);
        return {
          id: id('N',i+1),
          name: syllableName(r,tone),
          role: choice(r, LEX.roles),
          trait: choice(r, LEX.traits),
          faction: fac?.id || null
        }
      });
    }

    function genItems(r, tone){
      const count = Math.min(18, 8 + Math.floor(r()*10));
      return Array.from({length:count}, (_,i)=>{
        const t = choice(r, LEX.itemTypes);
        const rare = choice(r, LEX.rarities);
        return {
          id: id('I',i+1),
          name: `${syllableName(r,tone)}の${t}`,
          type: t,
          rarity: rare,
          value: Math.floor((10+ r()*90) * (1 + 0.3*LEX.rarities.indexOf(rare)))
        }
      });
    }

    function genQuests(r, tone, count, npcs, locations, items, difficulty){
      const q = [];
      const scale = DIFF_MULT[difficulty] || 1.0;
      for(let i=0;i<count;i++){
        const giver = choice(r, npcs);
        const loc = choice(r, locations);
        const verb = choice(r, LEX.verbs);
        const keyItem = choice(r, items);
        const level = Math.max(1, Math.round((i+1)*scale + r()*3));
        const objectives = [
          `${loc.name}で手掛かりを見つける`,
          `${giver.name}(${giver.role})に報告する`,
          `${keyItem.name}を入手する`
        ];
        // 依存関係:稀に前のクエストを前提にする
        let dependsOn = null;
        if(i>0 && r()<0.4){ dependsOn = q[Math.floor(r()*i)].id; }
        // ツイストは低確率で
        const twist = r()<0.35 ? choice(r, LEX.twists) : null;
        const rewardGold = Math.floor((100+ r()*200) * scale * (1 + i*0.05));
        const rewardItems = pickN(r, items, r()<0.6?1:2).map(o=>o.id);
        q.push({
          id: id('Q',i+1),
          title: `${verb}:${loc.name}`,
          level,
          giver: giver.id,
          location: loc.id,
          objectives,
          requires: dependsOn,
          reward: { gold: rewardGold, items: rewardItems, extra: r()<0.25? choice(r, LEX.rewardsExtra): null },
          twist
        });
      }
      return q;
    }

    function assembleWorld(input){
      const seed = input.seed || `${Date.now().toString(36)}-${cyrb128(input.worldName + (input.themes||''))}`;
      const r = rngFromSeed(seed);
      const tone = input.tone || 'classic';
      const factions = genFactions(r, input.themes);
      const locations = genLocations(r, input.themes);
      const npcs = genNPCs(r, tone, factions);
      const items = genItems(r, tone);
      const quests = genQuests(r, tone, input.questCount, npcs, locations, items, input.difficulty);
      return { meta: { seed, createdAt: new Date().toISOString(), worldName: input.worldName||syllableName(r,tone), themes: input.themes, difficulty: input.difficulty, tone }, factions, locations, npcs, items, quests };
    }

    /* =========================
     *  出力レンダリング
     * ========================= */
    function renderText(world){
      const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
      const NPC = idmap(world.npcs);
      const LOC = idmap(world.locations);
      const ITM = idmap(world.items);

      const lines = [];
      lines.push(`# 世界:${world.meta.worldName}`);
      lines.push(`- テーマ:${world.meta.themes||'—'} / トーン:${world.meta.tone} / 難易度:${world.meta.difficulty}`);
      lines.push(`- 生成日時:${new Date(world.meta.createdAt).toLocaleString()}`);
      lines.push(`- シード:${world.meta.seed}`);
      lines.push(`\n## 勢力(${world.factions.length})`);
      world.factions.forEach(f=>{ lines.push(`- [${f.id}] ${f.name}|目的:${f.goal}|態度:${f.vibe}`) });
      lines.push(`\n## 場所(${world.locations.length})`);
      world.locations.forEach(l=>{ lines.push(`- [${l.id}] ${l.name}|特徴:${l.feature}`) });
      lines.push(`\n## NPC(${world.npcs.length})`);
      world.npcs.forEach(n=>{ lines.push(`- [${n.id}] ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}`) });
      lines.push(`\n## アイテム(${world.items.length})`);
      world.items.forEach(i=>{ lines.push(`- [${i.id}] ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}`) });
      lines.push(`\n## クエスト(${world.quests.length})`);
      world.quests.forEach(q=>{
        const giver = NPC[q.giver]?.name || q.giver;
        const loc = LOC[q.location]?.name || q.location;
        const req = q.requires? `(前提:${q.requires})` : '';
        lines.push(`\n### [${q.id}] ${q.title} Lv.${q.level} ${req}`);
        lines.push(`- 依頼主:${giver}`);
        lines.push(`- 場所:${loc}`);
        lines.push(`- 目的:`);
        q.objectives.forEach(o=>lines.push(`  - ${o}`));
        const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
        lines.push(`- 報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}`);
        if(q.twist) lines.push(`- ツイスト:${q.twist}`);
      });
      return lines.join('\n');
    }

    function renderHTML(world){
      const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
      const NPC = idmap(world.npcs);
      const LOC = idmap(world.locations);
      const ITM = idmap(world.items);

      const head = `
        <h1>世界:${world.meta.worldName}</h1>
        <div class="kv text-sm text-slate-600 gap-x-2">
          <span class="badge">トーン:${world.meta.tone}</span>
          <span class="badge">難易度:${world.meta.difficulty}</span>
          <span class="badge">クエスト:${world.quests.length}</span>
          <span class="badge">シード:${world.meta.seed}</span>
        </div>
        <p class="mt-2 text-sm text-slate-600">テーマ:${world.meta.themes||'—'} / 生成日時:${new Date(world.meta.createdAt).toLocaleString()}</p>
      `;

      const factions = `
        <h2>勢力(${world.factions.length})</h2>
        <ul>
          ${world.factions.map(f=>`<li><code>[${f.id}]</code> ${f.name}|目的:${f.goal}|態度:${f.vibe}</li>`).join('')}
        </ul>
      `;

      const locs = `
        <h2>場所(${world.locations.length})</h2>
        <ul>
          ${world.locations.map(l=>`<li><code>[${l.id}]</code> ${l.name}|特徴:${l.feature}</li>`).join('')}
        </ul>
      `;

      const npcs = `
        <h2>NPC(${world.npcs.length})</h2>
        <ul>
          ${world.npcs.map(n=>`<li><code>[${n.id}]</code> ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}</li>`).join('')}
        </ul>
      `;

      const items = `
        <h2>アイテム(${world.items.length})</h2>
        <ul>
          ${world.items.map(i=>`<li><code>[${i.id}]</code> ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}</li>`).join('')}
        </ul>
      `;

      const quests = `
        <h2>クエスト(${world.quests.length})</h2>
        ${world.quests.map(q=>{
          const giver = NPC[q.giver]?.name || q.giver;
          const loc = LOC[q.location]?.name || q.location;
          const req = q.requires? `(前提:${q.requires})` : '';
          const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
          return `
            <details class="quest">
              <summary><strong><code>[${q.id}]</code> ${q.title}</strong> <span class="text-sm text-slate-600">Lv.${q.level} ${req}</span></summary>
              <div class="mt-2 text-sm">
                <div>依頼主:${giver}</div>
                <div>場所:${loc}</div>
                <div class="mt-1">目的:</div>
                <ul>
                  ${q.objectives.map(o=>`<li>${o}</li>`).join('')}
                </ul>
                <div class="mt-1">報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}</div>
                ${q.twist? `<div class="mt-1 text-rose-700">ツイスト:${q.twist}</div>`:''}
              </div>
            </details>`;
        }).join('')}
      `;

      return [head, factions, locs, npcs, items, quests].join('');
    }

    function renderCards(world){
      const $cards = document.getElementById('cards');
      $cards.innerHTML = '';
      const make = (title, body)=>{
        const el = document.createElement('div');
        el.className = 'rounded-2xl border p-4 bg-white';
        el.innerHTML = `<div class="text-sm font-bold mb-2">${title}</div><div class="text-xs text-slate-700 whitespace-pre-wrap">${body}</div>`;
        $cards.appendChild(el);
      };
      make('ワールド', `名前:${world.meta.worldName}\n難易度:${world.meta.difficulty}\nトーン:${world.meta.tone}\nシード:${world.meta.seed}`);
      make('勢力', world.factions.map(f=>`[${f.id}] ${f.name}/目的:${f.goal}`).join('\n'));
      make('場所', world.locations.map(l=>`[${l.id}] ${l.name}/${l.feature}`).join('\n'));
      make('NPC', world.npcs.slice(0,12).map(n=>`[${n.id}] ${n.name}/${n.role}`).join('\n'));
      make('アイテム', world.items.slice(0,15).map(i=>`[${i.id}] ${i.name}/${i.rarity}`).join('\n'));
      make('クエスト', world.quests.map(q=>`[${q.id}] ${q.title} Lv.${q.level}${q.requires? '(前提:'+q.requires+')':''}`).join('\n'));
    }

    /* =========================
     *  CSV/JSON/コピー/保存
     * ========================= */
    function toCSV(rows){
      return rows.map(r=> r.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
    }
    function exportCSVs(world){
      const npcRows = [["id","name","role","trait","faction"]].concat(world.npcs.map(n=>[n.id,n.name,n.role,n.trait,n.faction||'']));
      const itemRows = [["id","name","type","rarity","value"]].concat(world.items.map(i=>[i.id,i.name,i.type,i.rarity,i.value]));
      const questRows = [["id","title","level","giver","location","requires","objectives","reward_gold","reward_items","twist"]].concat(
        world.quests.map(q=>[
          q.id, q.title, q.level, q.giver, q.location, q.requires||'', q.objectives.join(' / '), q.reward.gold, q.reward.items.join('|'), q.twist||''
        ])
      );
      const files = [
        {name:`${world.meta.worldName}_NPC.csv`, data: toCSV(npcRows)},
        {name:`${world.meta.worldName}_Items.csv`, data: toCSV(itemRows)},
        {name:`${world.meta.worldName}_Quests.csv`, data: toCSV(questRows)}
      ];
      files.forEach(f=>{
        const blob = new Blob(["\ufeff"+f.data], {type:'text/csv'});
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = f.name; a.click(); URL.revokeObjectURL(a.href);
      });
    }
    function downloadJSON(world){
      const blob = new Blob([JSON.stringify(world, null, 2)], {type:'application/json'});
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `${world.meta.worldName}_world.json`; a.click(); URL.revokeObjectURL(a.href);
    }
    function copyText(text){
      navigator.clipboard.writeText(text).then(()=>{
        toast('テキストをコピーしました');
      });
    }
    function saveLocal(world){ localStorage.setItem('quest_foundry_last', JSON.stringify(world)); toast('保存しました'); }
    function loadLocal(){ const s=localStorage.getItem('quest_foundry_last'); if(!s){ toast('保存データなし'); return null; } try{ return JSON.parse(s);}catch(e){ toast('読込失敗'); return null; } }

    /* =========================
     *  UI
     * ========================= */
    function toast(msg){
      const t = document.createElement('div');
      t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg';
      t.textContent = msg; document.body.appendChild(t);
      setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .6s'; }, 1600);
      setTimeout(()=> t.remove(), 2300);
    }

    let lastInput = null;
    let lastWorld = null;

    function currentInput(){
      const worldName = document.getElementById('worldName').value.trim();
      const themes = document.getElementById('themes').value.trim();
      const difficulty = document.getElementById('difficulty').value;
      const questCount = Math.max(1, Math.min(30, parseInt(document.getElementById('questCount').value || '8')));
      const seed = document.getElementById('seed').value.trim();
      const tone = document.getElementById('tone').value;
      return { worldName, themes, difficulty, questCount, seed, tone };
    }

    function applyWorld(world){
      lastWorld = world;
      document.getElementById('meta').textContent = `ワールド:${world.meta.worldName} / クエスト:${world.quests.length}件`;
      document.getElementById('outText').innerHTML = renderHTML(world);
      document.getElementById('outJSON').textContent = JSON.stringify(world, null, 2);
      renderCards(world);
    }

    function generate(withNewSeed=false){
      const input = currentInput();
      if(withNewSeed && !document.getElementById('lockSeed').checked){ input.seed = ''; }
      if(!input.seed) { input.seed = cyrb128((input.worldName||'World') + (input.themes||'') + Date.now()); document.getElementById('seed').value = input.seed; }
      lastInput = input;
      const world = assembleWorld(input);
      applyWorld(world);
    }

    // イベント
    document.getElementById('btnGenerate').addEventListener('click', ()=> generate(false));
    document.getElementById('btnRegenerate').addEventListener('click', ()=> generate(false));
    document.getElementById('btnShuffleSeed').addEventListener('click', ()=> generate(true));
    document.getElementById('btnCopyText').addEventListener('click', ()=>{ if(lastWorld) copyText(renderText(lastWorld)); });
    document.getElementById('btnDownloadJSON').addEventListener('click', ()=>{ if(lastWorld) downloadJSON(lastWorld); });
    document.getElementById('btnExportCSV').addEventListener('click', ()=>{ if(lastWorld) exportCSVs(lastWorld); });
    document.getElementById('btnToggleJson').addEventListener('click', ()=>{ document.getElementById('jsonBlock').classList.toggle('hidden'); });
    document.getElementById('btnSave').addEventListener('click', ()=>{ if(lastWorld) saveLocal(lastWorld); });
    document.getElementById('btnLoad').addEventListener('click', ()=>{ const w=loadLocal(); if(w) applyWorld(w); });
    document.getElementById('btnPrint').addEventListener('click', ()=> window.print());

    // 初期プレースホルダ生成
    window.addEventListener('DOMContentLoaded', ()=>{
      document.getElementById('worldName').value = '運命の剣界';
      document.getElementById('themes').value = '古代遺跡 風の精霊 砂漠 旅人ギルド';
      generate(true);
    });
  </script>
</body>
</html>

MailLite — シンプルWebメール

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MailLite — シンプルWebメール</title>
<style>
  :root{
    --bg:#f6f7fb;
    --panel:#ffffff;
    --text:#1f2937;
    --muted:#6b7280;
    --primary:#4f46e5;
    --primary-weak:#eef2ff;
    --border:#e5e7eb;
    --danger:#ef4444;
    --success:#10b981;
    --warning:#f59e0b;
  }
  .dark{
    --bg:#0b0e15;
    --panel:#0f1623;
    --text:#e5e7eb;
    --muted:#9ca3af;
    --primary:#8b5cf6;
    --primary-weak:#221a36;
    --border:#1f2937;
    --danger:#f87171;
    --success:#34d399;
    --warning:#fbbf24;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;background:var(--bg);color:var(--text);
    font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;
  }
  .app{
    display:grid;grid-template-rows:56px 1fr;height:100%;
  }
  /* Topbar */
  .topbar{
    display:flex;align-items:center;gap:12px;
    padding:0 16px;border-bottom:1px solid var(--border);background:var(--panel);
    position:sticky;top:0;z-index:5;
  }
  .logo{
    display:flex;align-items:center;gap:10px;font-weight:700;
    letter-spacing:.2px;
  }
  .badge{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--primary-weak);color:var(--primary)}
  .search{
    margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--bg);
    padding:6px 10px;border-radius:10px;border:1px solid var(--border);min-width:220px;max-width:460px;flex:1;
  }
  .search input{border:none;background:transparent;outline:none;color:var(--text);width:100%}
  .icon{width:18px;height:18px;display:inline-block;flex:0 0 18px}
  .btn{
    display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
    border:1px solid var(--border);background:var(--panel);cursor:pointer;color:var(--text);
  }
  .btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
  .btn.ghost{background:transparent}
  .btn:disabled{opacity:.6;cursor:not-allowed}

  /* Layout */
  .layout{
    display:grid;grid-template-columns:260px 360px 1fr;gap:12px;padding:12px;height:calc(100vh - 56px);
  }
  .panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0}
  .sidebar{padding:12px}
  .compose-block{padding:12px}
  .compose-button{width:100%;justify-content:center}
  .nav-group{margin-top:8px}
  .nav-item{
    display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;color:var(--text);text-decoration:none;cursor:pointer;
  }
  .nav-item:hover{background:var(--primary-weak)}
  .nav-item.active{background:var(--primary);color:#fff}
  .nav-item .count{margin-left:auto;opacity:.8}

  /* List */
  .list-toolbar{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--border)}
  .list{overflow:auto}
  .msg{
    display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;
  }
  .msg:hover{background:var(--primary-weak)}
  .msg.unread{background:linear-gradient(0deg,transparent,transparent), var(--panel);font-weight:600}
  .msg .from{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .subject{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .snippet{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .meta{display:flex;flex-direction:column;align-items:end;gap:4px;color:var(--muted)}
  .chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);font-size:11px}
  .star{cursor:pointer;opacity:.7}
  .star.active{opacity:1}
  .avatar{
    width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#22c1c3);
    display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
  }

  /* Reader */
  .reader-head{
    padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
  }
  .reader-title{font-size:18px;font-weight:700}
  .reader-meta{color:var(--muted);font-size:12px}
  .reader-actions{margin-left:auto;display:flex;gap:8px}
  .reader-body{padding:16px;overflow:auto}
  .empty{display:grid;place-items:center;height:100%;color:var(--muted)}

  /* Modal */
  .modal{
    position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20;
  }
  .modal.open{display:flex}
  .modal-card{
    width:min(920px,94vw);max-height:88vh;overflow:auto;background:var(--panel);border:1px solid var(--border);
    border-radius:16px;
  }
  .modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
  .modal-body{padding:16px;display:grid;gap:10px}
  .field{display:grid;gap:6px}
  .input, textarea{
    width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);
    background:transparent;color:var(--text);outline:none;
  }
  textarea{min-height:220px;resize:vertical}
  .row{display:flex;gap:10px;flex-wrap:wrap}
  .grow{flex:1}

  /* Responsive */
  @media (max-width: 1100px){
    .layout{grid-template-columns:220px 1fr}
    .reader{display:none}
    .layout.show-reader .list{display:none}
    .layout.show-reader .reader{display:flex}
  }
  @media (max-width: 640px){
    .layout{grid-template-columns:1fr}
    .sidebar{display:none}
  }
</style>
</head>
<body>
<div class="app">
  <!-- Topbar -->
  <div class="topbar">
    <div class="logo">
      <svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M3 7.5 12 13l9-5.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-9Z" stroke="currentColor" stroke-width="1.5"/>
        <path d="m3 7.5 9-5 9 5" stroke="currentColor" stroke-width="1.5"/>
      </svg>
      MailLite <span class="badge">beta</span>
    </div>

    <div class="search">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="m21 21-4.2-4.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/></svg>
      <input id="search" placeholder="メールを検索(差出人・件名・本文・ラベル)" />
      <button class="btn ghost" id="clearSearch" title="検索クリア">クリア</button>
    </div>

    <button class="btn" id="toggleDark" title="ダークモード">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="1.5"/></svg>
      主题
    </button>
    <button class="btn primary" id="composeBtn">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
      新規作成
    </button>
  </div>

  <!-- Main layout -->
  <div class="layout" id="layout">
    <!-- Sidebar -->
    <aside class="panel sidebar">
      <div class="compose-block">
        <button class="btn primary compose-button" id="composeBtn2">
          <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
          新規メール
        </button>
      </div>
      <nav class="nav-group" id="folders"></nav>
    </aside>

    <!-- List -->
    <section class="panel">
      <div class="list-toolbar">
        <button class="btn" id="markReadBtn" title="既読にする">既読</button>
        <button class="btn" id="markUnreadBtn" title="未読にする">未読</button>
        <button class="btn" id="archiveBtn" title="アーカイブ">アーカイブ</button>
        <button class="btn" id="deleteBtn" title="削除">削除</button>
        <div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
          <span class="chip"><span class="dot" style="width:8px;height:8px;border-radius:50%;background:var(--primary)"></span> ラベル</span>
        </div>
      </div>
      <div class="list" id="list"></div>
    </section>

    <!-- Reader -->
    <section class="panel reader" id="reader">
      <div class="empty" id="emptyState">メールを選択してください</div>
      <div style="display:none;flex-direction:column;height:100%;" id="readerWrap">
        <div class="reader-head">
          <div style="display:flex;align-items:center;gap:10px;min-width:0">
            <div class="avatar" id="readerAvatar">Y</div>
            <div style="min-width:0">
              <div class="reader-title" id="readerSubject">件名</div>
              <div class="reader-meta" id="readerMeta">From – To ・ 日付</div>
            </div>
          </div>
          <div class="reader-actions">
            <button class="btn" id="replyBtn" title="返信">返信</button>
            <button class="btn" id="starBtn" title="スター">
              <span id="starIcon">☆</span> スター
            </button>
            <button class="btn" id="archBtn" title="アーカイブ">アーカイブ</button>
            <button class="btn" id="trashBtn" title="削除" style="color:var(--danger)">削除</button>
          </div>
        </div>
        <div class="reader-body">
          <div id="readerBody"></div>
        </div>
      </div>
    </section>
  </div>
</div>

<!-- Compose Modal -->
<div class="modal" id="composeModal" aria-hidden="true">
  <div class="modal-card">
    <div class="modal-head">
      <strong>新規メッセージ</strong>
      <div class="row">
        <button class="btn" id="saveDraftBtn">下書き保存</button>
        <button class="btn primary" id="sendBtn">送信</button>
        <button class="btn" id="closeModalBtn">閉じる</button>
      </div>
    </div>
    <div class="modal-body">
      <div class="row">
        <div class="field grow">
          <label for="to">宛先(カンマ区切り)</label>
          <input class="input" id="to" placeholder="example@example.com, someone@domain.jp" />
        </div>
        <div class="field" style="min-width:160px;">
          <label for="label">ラベル</label>
          <input class="input" id="label" placeholder="work, personal など" />
        </div>
      </div>
      <div class="field">
        <label for="subject">件名</label>
        <input class="input" id="subject" placeholder="件名を入力" />
      </div>
      <div class="field">
        <label for="body">本文</label>
        <textarea id="body" placeholder="本文を入力"></textarea>
      </div>
    </div>
  </div>
</div>

<script>
/** ======= Simple Mail App (no backend) ======= */
const DB_KEY = "maillite-db-v1";
const QS = sel => document.querySelector(sel);
const QSA = sel => [...document.querySelectorAll(sel)];
const state = {
  currentFolder: "inbox",
  query: "",
  selectedIds: new Set(),
  currentId: null,
  db: { messages: [] }
};
const FOLDERS = [
  {id:"inbox",   name:"受信箱",    icon:"📥"},
  {id:"starred", name:"スター",    icon:"⭐"},
  {id:"sent",    name:"送信済み",  icon:"📤"},
  {id:"drafts",  name:"下書き",    icon:"📝"},
  {id:"archive", name:"アーカイブ",icon:"🗄️"},
  {id:"spam",    name:"迷惑",      icon:"🚫"},
  {id:"trash",   name:"ゴミ箱",    icon:"🗑️"},
];

function initDB(){
  const saved = localStorage.getItem(DB_KEY);
  if(saved){
    state.db = JSON.parse(saved);
    return;
  }
  // Seed sample messages
  const now = Date.now();
  const demo = [
    mkMsg("suzuki@example.com","ようこそ MailLite へ","MailLite をお試しいただきありがとうございます!\n\nこのメールはデモです。",["welcome"], now-3600_000),
    mkMsg("shop@ec.example.com","【お知らせ】サマーセール開催!","最大 50%OFF。今すぐチェック!",["promo"], now-7200_000),
    mkMsg("boss@company.jp","明日の打合せ議題","・リリース計画\n・障害対応\n・コスト見直し",["work"], now-86400_000, true),
    mkMsg("friend@chat.jp","週末の予定どう?","映画かカラオケ行かない?",["personal"], now-5400_000),
    mkMsg("security@service.jp","ログイン通知","新しい端末からログインがありました。",["security"], now-9600_000),
  ];
  demo[2].unread = false;
  state.db.messages = demo;
  persist();
}
function mkMsg(from, subject, body, labels=[], time=Date.now(), starred=false){
  const id = crypto.randomUUID();
  const initials = (from.split("@")[0][0]||"U").toUpperCase();
  return {
    id, box:"inbox", from, to:[], subject, body, labels, starred, unread:true,
    date: time, initials
  };
}
function persist(){ localStorage.setItem(DB_KEY, JSON.stringify(state.db)); }

function formatDate(ts){
  const d = new Date(ts);
  const pad = n => String(n).padStart(2,"0");
  return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

// Render folders
function renderFolders(){
  const el = QS("#folders");
  el.innerHTML = "";
  for(const f of FOLDERS){
    const count = countFolder(f.id);
    const a = document.createElement("a");
    a.className = "nav-item" + (state.currentFolder===f.id?" active":"");
    a.dataset.id = f.id;
    a.innerHTML = `<span>${f.icon}</span><span>${f.name}</span><span class="count">${count||""}</span>`;
    a.addEventListener("click", ()=>{
      state.currentFolder = f.id;
      state.selectedIds.clear();
      state.currentId = null;
      render();
    });
    el.appendChild(a);
  }
}
function countFolder(folder){
  return filterByFolder(state.db.messages, folder).length;
}
function filterByFolder(list, folder){
  switch(folder){
    case "inbox":   return list.filter(m=>m.box==="inbox");
    case "starred": return list.filter(m=>m.starred && m.box!=="trash");
    case "sent":    return list.filter(m=>m.box==="sent");
    case "drafts":  return list.filter(m=>m.box==="drafts");
    case "archive": return list.filter(m=>m.box==="archive");
    case "spam":    return list.filter(m=>m.box==="spam");
    case "trash":   return list.filter(m=>m.box==="trash");
    default:        return list;
  }
}

// Search
function matchesQuery(m,q){
  if(!q) return true;
  const s = q.toLowerCase();
  const hay = [
    m.from, (m.to||[]).join(","), m.subject, m.body, (m.labels||[]).join(",")
  ].join("\n").toLowerCase();
  return hay.includes(s);
}

// Render list
function renderList(){
  const wrap = QS("#list");
  const items = filterByFolder(state.db.messages, state.currentFolder)
    .filter(m=>matchesQuery(m, state.query))
    .sort((a,b)=>b.date-a.date);
  wrap.innerHTML = "";
  if(items.length===0){
    wrap.innerHTML = `<div class="empty" style="height:100%;">${state.query? "検索結果がありません":"このフォルダは空です"}</div>`;
    return;
  }
  for(const m of items){
    const row = document.createElement("div");
    row.className = "msg" + (m.unread?" unread":"");
    row.dataset.id = m.id;
    const starClass = m.starred? "active":"";
    row.innerHTML = `
      <div class="avatar" title="${m.from}">${m.initials}</div>
      <div style="min-width:0">
        <div class="from">${m.from}</div>
        <div class="subject">${m.subject}</div>
        <div class="snippet">${(m.labels?.length? m.labels.map(l=>"#"+l).join(" ")+" · ":"")}${m.body.replace(/\n/g," ").slice(0,120)}</div>
      </div>
      <div class="meta">
        <div>${formatDate(m.date)}</div>
        <div class="star ${starClass}" data-star-id="${m.id}" title="スター">${m.starred?"★":"☆"}</div>
      </div>
    `;
    row.addEventListener("click", (e)=>{
      // If star clicked, don't open
      if(e.target && e.target.dataset.starId){ return; }
      openMessage(m.id);
    });
    row.querySelector(".star").addEventListener("click",(e)=>{
      e.stopPropagation();
      toggleStar(m.id);
    });
    wrap.appendChild(row);
  }
}

// Reader
function openMessage(id){
  const m = state.db.messages.find(x=>x.id===id);
  if(!m) return;
  state.currentId = id;
  m.unread = false;
  persist();
  QS("#emptyState").style.display = "none";
  QS("#readerWrap").style.display = "flex";
  QS("#readerSubject").textContent = m.subject || "(件名なし)";
  QS("#readerMeta").textContent    = `From: ${m.from}  /  To: ${(m.to||[]).join(", ")||"(なし)"} ・ ${formatDate(m.date)}`; 
  QS("#readerBody").innerHTML      = safeHtml(m.body).replace(/\n/g,"<br>");
  QS("#readerAvatar").textContent  = m.initials || "U";
  QS("#starIcon").textContent      = m.starred ? "★" : "☆";
  // Mobile: show reader
  QS("#layout").classList.add("show-reader");
  render();
}

function safeHtml(s=""){
  return s.replace(/[&<>"']/g, ch => ({
    "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"
  }[ch]));
}

// Actions
function getSelectedOrCurrentIds(){
  if(state.selectedIds.size>0) return [...state.selectedIds];
  if(state.currentId) return [state.currentId];
  return [];
}
function markRead(read=true){
  for(const id of getSelectedOrCurrentIds()){
    const m = state.db.messages.find(x=>x.id===id);
    if(m) m.unread = !read;
  }
  persist(); render();
}
function moveTo(box){
  for(const id of getSelectedOrCurrentIds()){
    const m = state.db.messages.find(x=>x.id===id);
    if(m) m.box = box;
  }
  // If current message was moved out of view, clear reader
  if(state.currentId){
    const cm = state.db.messages.find(x=>x.id===state.currentId);
    const visible = filterByFolder([cm], state.currentFolder).length>0;
    if(!visible){ state.currentId = null; QS("#readerWrap").style.display="none"; QS("#emptyState").style.display="grid"; }
  }
  persist(); render();
}
function toggleStar(id){
  const m = state.db.messages.find(x=>x.id===id);
  if(m){ m.starred = !m.starred; persist(); render(); if(state.currentId===id) QS("#starIcon").textContent=m.starred?"★":"☆"; }
}

// Compose
function openCompose(prefill={}){
  QS("#composeModal").classList.add("open");
  QS("#to").value      = prefill.to?.join(", ") || "";
  QS("#subject").value = prefill.subject || "";
  QS("#body").value    = prefill.body || "";
  QS("#label").value   = (prefill.labels||[]).join(", ");
}
function closeCompose(){ QS("#composeModal").classList.remove("open"); }

function saveDraft(){
  const draft = collectForm();
  draft.box = "drafts";
  draft.unread = false;
  draft.from = "me@local";
  draft.id = crypto.randomUUID();
  state.db.messages.push(draft);
  persist(); closeCompose(); render();
  alert("下書きを保存しました。");
}
function sendMail(){
  const msg = collectForm();
  if(!msg.to.length){ alert("宛先を入力してください"); return; }
  msg.box = "sent";
  msg.unread = false;
  msg.from = "me@local";
  msg.id = crypto.randomUUID();
  state.db.messages.push(msg);
  persist(); closeCompose(); render();
  alert("送信しました(デモ動作:実送信なし)");
}
function collectForm(){
  const to = QS("#to").value.split(",").map(s=>s.trim()).filter(Boolean);
  const subject = QS("#subject").value.trim() || "(件名なし)";
  const body = QS("#body").value;
  const labels = QS("#label").value.split(",").map(s=>s.trim()).filter(Boolean);
  return { to, subject, body, labels, date: Date.now(), starred:false, unread:true, initials:"M" };
}

// Selection (click+Ctrl/Shift optional simplified)
QS("#list").addEventListener("click",(e)=>{
  const row = e.target.closest(".msg");
  if(!row) return;
  if(e.ctrlKey || e.metaKey){
    const id = row.dataset.id;
    if(state.selectedIds.has(id)) state.selectedIds.delete(id); else state.selectedIds.add(id);
    row.classList.toggle("selected");
  }
});

// Topbar controls
QS("#composeBtn").onclick = ()=>openCompose();
QS("#composeBtn2").onclick = ()=>openCompose();
QS("#closeModalBtn").onclick = closeCompose;
QS("#saveDraftBtn").onclick = saveDraft;
QS("#sendBtn").onclick = sendMail;

QS("#search").addEventListener("input",(e)=>{ state.query = e.target.value.trim(); renderList(); });
QS("#clearSearch").onclick = ()=>{ QS("#search").value=""; state.query=""; renderList(); };

QS("#toggleDark").onclick = ()=>{
  document.body.classList.toggle("dark");
  localStorage.setItem("maillite-theme", document.body.classList.contains("dark")? "dark":"light");
};

// List toolbar actions
QS("#markReadBtn").onclick = ()=>markRead(true);
QS("#markUnreadBtn").onclick = ()=>markRead(false);
QS("#archiveBtn").onclick = ()=>moveTo("archive");
QS("#deleteBtn").onclick = ()=>moveTo("trash");

// Reader actions
QS("#replyBtn").onclick = ()=>{
  const m = state.db.messages.find(x=>x.id===state.currentId);
  if(!m) return;
  openCompose({ to:[m.from], subject:"Re: "+m.subject, body:`\n\n--- ${m.from} さんのメッセージ ---\n${m.body}`, labels:["reply"] });
};
QS("#starBtn").onclick = ()=>{ if(state.currentId) toggleStar(state.currentId); };
QS("#archBtn").onclick = ()=>moveTo("archive");
QS("#trashBtn").onclick = ()=>moveTo("trash");

// Clicking outside modal to close
QS("#composeModal").addEventListener("click",(e)=>{ if(e.target===QS("#composeModal")) closeCompose(); });

// Mobile back from reader by clicking empty area (or press Escape)
document.addEventListener("keydown",(e)=>{
  if(e.key==="Escape"){
    if(QS("#composeModal").classList.contains("open")) closeCompose();
    else QS("#layout").classList.remove("show-reader");
  }
});

// Initial render
function render(){
  renderFolders();
  renderList();
}
(function boot(){
  initDB();
  render();
  // Theme
  if(localStorage.getItem("maillite-theme")==="dark"){ document.body.classList.add("dark"); }
})();
</script>
</body>
</html>