「これ、軽量モデルのフラッシュ君で書いたの…?」 ── 秀GASマネージャーを持たせたら、最速AIがネイティブ級スマホアプリを爆速生成する怪物になった話
はじめに ―― 「これ、スマホで使えたら最強じゃないか?」
前回の記事で、自作のGmail爆速ビューア『Mクイック』をブラウザゼロで修復し、0.5秒の連打にも完全追従する怪物に仕立て上げた話を公開しました。
- 本番WebアプリをAIにブラウザゼロで直させたら、0.5秒の連打にも完全追従する怪物になった話 ―― 秀GASマネージャー実戦証明と『からくりポケット』の同梱公開
PCのブラウザで叩くMクイックは、まさに快適そのもの。数千件のメールがIndexedDBから瞬時に立ち上がり、キーボード操作でサクサク処理できる相棒になりました。
ですが、使い込んでいるうちに、ごく自然な欲求が湧き上がってきたのです。
「出先や移動中に、スマホでもこの速度でGmailを確認できたら、どれだけ助かるだろうか?」
早速スマートフォンでアクセスしてみました。
……しかし、現実は甘くありませんでした。
!スマホ表示が崩れていた初期状態
PC用の4列テーブル(日付・差出人・件名・操作)を無理やりスマホの画面幅(約390px)に詰め込んだため、ヘッダーのボタンは画面外へ吹き飛び、件名列は押し潰され、とても実用に耐える状態ではなかったのです。
「やっぱりGASのWebアプリでスマホアプリなんて無理なのか……?」
普通ならここで諦めるか、「まあPC専用ツールだから」と割り切るところです。
しかし、私には 秀GASマネージャー と、頼れる相棒 Gemini Flash(フラッシュ君) がいました。
ここから、「軽量モデル×ガスマネージャー」が起こす、前代未聞のスマホアプリ開発劇が始まったのです。
!スマホになった!
---
爆誕した『Mクイック』スマホ版 ―― ネイティブアプリと見分けがつかない3大機能
結論から申し上げます。完成したものは、「App Storeにあるネイティブアプリそのもの」 でした。
GAS特有のもっさり感や「Webページを見ている感」は完全に消え去っています。
!スマホ版Mクイックの完成画面(ホワイト・グリーン・ダークの3色テーマ)
1. PWA(Progressive Web App)による「全画面アプリ化」
スマホのブラウザ(SafariやChrome)で開き、メニューから 「ホーム画面に追加」 を押すだけ。
それだけで、ブラウザのアドレスバーや戻るボタンが完全に消え去り、全画面スタンドアローンアプリとして起動します(※iPhoneでは専用の稲妻アイコン、AndroidではGAS仕様により親しみのある青いGoogleの「G」アイコンが配置されます)。
高価なMacも、年間数万円のApple Developer登録も、厳しいアプリ審査も一切不要。
Googleの無料インフラだけで、完全なスマホアプリが手元で動くのです。
2. Gmail公式準拠の「左右スワイプアクション」
スマートフォンで小さなボタンを親指で狙うのはストレスです。
そこで、メールカードを左右に指先でスワイプして処理できるようにしました。
- 👉 右へスワイプ(緑): 指を離すとその場で 「アーカイブ」
- 👈 左へスワイプ(赤): 指を離すとその場で 「ゴミ箱(削除)」
途中で指を戻せばバネのようにシュッと元の位置に戻る、滑らかなタッチアニメーションを実装しています。
3. 触覚フィードバック(Haptic微振動)
スワイプが閾値を超えた瞬間や、ボタンをタップした瞬間に、スマホ本体が 「コトッ」「トッ」と心地よく微振動 します。
わずか数行の Vibration API ですが、これが入るだけで「Webページを操作している感覚」が脳内から消え、本物のネイティブアプリの質感に化けました。
---
Mクイック のコード(自分でメールビューアを作りたい方へ)
この節では当初、Mクイック の写しをワンクリックで作れるリンクを置いていました。写しを配る形はやめて、コードをそのまま載せることにしました(2026-11-05)。
自分で Gmail のビューアを作ってみたい方の参考になればと思います。Apps Script の新しいプロジェクトに、下の 3 つのファイルを作って貼り、ウェブアプリとしてデプロイすれば動きます。
作るときに気をつけたい所を 2 つだけ書いておきます。
- メール本文は sandbox 付きの iframe で表示しています。 getBody() で取れる HTML は、Gmail の画面と違ってスクリプトが取り除かれていません。Index.html の EMAIL_SAFE_HEAD の周りを見てください。
- 添付ファイル名のように送り主が決める文字を onclick="…" に入れるときは、JS と HTML の両方の処理をしています。 Index.html の escapeJsStr です。
載せているのは、この 2 つを足した 11/05 の版です。
<details><summary>コード.gs(サーバー側・373 行)</summary>
/**
* Webアプリケーションのエントリポイント
*/
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail 一覧ダッシュボード')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
/**
* ユーザー作成のラベル一覧を取得
*/
function getUserLabels() {
try {
const labels = GmailApp.getUserLabels();
return labels.map(label => label.getName());
} catch (e) {
console.error('getUserLabels error:', e);
return [];
}
}
/**
* 条件に応じたメール一覧を取得 (N+1問題解消・添付走査の最適化版)
*/
function getEmailList(count, mode, _fetchDetails) {
try {
let query = '';
if (mode === 'inbox') {
query = 'in:inbox';
} else if (mode === 'sent') {
query = 'in:sent';
} else if (mode === 'trash') {
query = 'in:trash';
} else if (mode === 'archive') {
query = '-in:inbox -in:sent -in:trash';
} else if (mode === 'all') {
query = '-in:trash';
} else {
query = 'label:"' + mode + '" -in:trash';
}
const limit = (count === 'all' || typeof count !== 'number') ? 300 : count;
const threads = GmailApp.search(query, 0, Math.min(limit, 500));
if (threads.length === 0) return [];
const messagesPerThread = GmailApp.getMessagesForThreads(threads);
const result = [];
const userEmail = Session.getActiveUser().getEmail();
const timeZone = Session.getScriptTimeZone();
threads.forEach((thread, index) => {
const messages = messagesPerThread[index];
if (!messages || messages.length === 0) return;
const latestMsg = messages[messages.length - 1];
const dateObj = latestMsg.getDate();
const dateStr = Utilities.formatDate(dateObj, timeZone, 'yyyy/MM/dd');
const timeStr = Utilities.formatDate(dateObj, timeZone, 'HH:mm');
const fromRaw = (mode === 'sent') ? latestMsg.getTo() : latestMsg.getFrom();
const fromMatch = fromRaw.match(/^(.*?)(?:<.*?>)?$/);
const fromClean = (fromMatch && fromMatch[1].trim()) ? fromMatch[1].replace(/"/g, '').trim() : fromRaw;
let hasAttachment = false;
for (let i = messages.length - 1; i >= 0; i--) {
try {
if (messages[i].getAttachments({ includeInlineImages: false }).length > 0) {
hasAttachment = true;
break;
}
} catch (e) {
break;
}
}
const threadLabels = thread.getLabels().map(l => l.getName());
const labelIds = [];
if (thread.isInInbox()) labelIds.push('INBOX');
if (thread.isInTrash()) labelIds.push('TRASH');
if (mode === 'sent' || messages.some(m => m.getFrom().includes(userEmail))) {
labelIds.push('SENT');
}
result.push({
id: thread.getId(),
subject: thread.getFirstMessageSubject() || '(件名なし)',
from: fromClean,
date: dateStr,
time: timeStr,
isUnread: thread.isUnread(),
hasAttachment: hasAttachment,
attachmentNames: [],
labels: threadLabels,
labelIds: labelIds
});
});
return result;
} catch (e) {
console.error('getEmailList error:', e);
throw new Error(e.message);
}
}
/**
* スレッドの詳細を取得し、既読化する
*/
function getThreadDetails(threadId) {
try {
const thread = GmailApp.getThreadById(threadId);
if (!thread) return { success: false, error: 'スレッドが見つかりません' };
if (thread.isUnread()) {
thread.markRead();
}
const messages = thread.getMessages();
const timeZone = Session.getScriptTimeZone();
const details = messages.map(msg => {
const msgId = msg.getId();
const body = msg.getBody();
const plainBody = body ? '' : msg.getPlainBody();
const attachments = msg.getAttachments();
return {
id: msgId,
from: msg.getFrom(),
to: msg.getTo(),
cc: msg.getCc(),
date: Utilities.formatDate(msg.getDate(), timeZone, 'yyyy/MM/dd HH:mm:ss'),
body: body,
plainBody: plainBody,
attachments: attachments.map((att, attIdx) => ({
msgId: msgId,
attIndex: attIdx,
name: att.getName(),
size: Math.round(att.getSize() / 1024) + ' KB',
mimeType: att.getContentType()
}))
};
});
return {
success: true,
subject: thread.getFirstMessageSubject() || '(件名なし)',
messages: details
};
} catch (e) {
console.error('getThreadDetails error:', e);
return { success: false, error: e.message };
}
}
/**
* ラベル付与とアーカイブ
*/
function updateLabelsAndArchive(threadId, labelName, isInboxMode) {
try {
const thread = GmailApp.getThreadById(threadId);
if (!thread) return { success: false, error: 'スレッドが見つかりません' };
if (labelName && labelName !== '__archive_only__') {
let label = GmailApp.getUserLabelByName(labelName);
if (!label) {
label = GmailApp.createLabel(labelName);
}
thread.addLabel(label);
}
if (isInboxMode) {
thread.moveToArchive();
}
return { success: true };
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* スレッドからラベルを解除
*/
function removeLabelFromThread(threadId, labelName) {
try {
const thread = GmailApp.getThreadById(threadId);
if (!thread) return { success: false, error: 'スレッドが見つかりません' };
const label = GmailApp.getUserLabelByName(labelName);
if (label) {
thread.removeLabel(label);
}
return { success: true };
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* メールをゴミ箱へ移動
*/
/**
* ゴミ箱から受信トレイへ復元
*/
/**
* ZIPエクスポート
*/
function exportEmailAsZip(threadId) {
try {
const thread = GmailApp.getThreadById(threadId);
if (!thread) return { success: false, error: 'スレッドが見つかりません' };
const messages = thread.getMessages();
const firstMsg = messages[0];
const timeZone = Session.getScriptTimeZone();
const userEmail = Session.getActiveUser().getEmail();
const timeStamp = Utilities.formatDate(firstMsg.getDate(), timeZone, 'yyyyMMdd_HHmm');
const subject = thread.getFirstMessageSubject() || '無題';
const cleanSubject = (subject.replace(/[\/\\?%*:|"<>]/g, '_').trim() || '無題').substring(0, 30);
const isSent = firstMsg.getFrom().includes(userEmail);
const rawTarget = isSent ? firstMsg.getTo() : firstMsg.getFrom();
const match = rawTarget.match(/^(.*?)(?:<.*?>)?$/);
const targetName = (match && match[1].trim()) ? match[1].replace(/["']/g, '').trim() : rawTarget;
const cleanTarget = (targetName.replace(/[\/\\?%*:|"<>]/g, '_').trim() || '相手先').substring(0, 15);
const baseName = `${timeStamp}_${cleanSubject}_${cleanTarget}`;
let fullHtml = `<html><head><meta charset="utf-8"><style>body{font-family:sans-serif;padding:20px;} h2{border-bottom:1px solid #ccc;padding-bottom:8px;} .header{color:#555;font-size:13px;margin-bottom:20px;line-height:1.6;} hr{border:none;border-top:1px solid #eee;margin:20px 0;}</style></head><body>`;
fullHtml += `<h2>${escapeHtmlServer(subject)}</h2>`;
const blobs = [];
const usedNames = new Set();
function getUniqueName(fileName) {
let name = fileName;
let counter = 1;
const dotIndex = name.lastIndexOf('.');
const base = dotIndex !== -1 ? name.substring(0, dotIndex) : name;
const ext = dotIndex !== -1 ? name.substring(dotIndex) : '';
while (usedNames.has(name)) {
name = `${base}(${counter})${ext}`;
counter++;
}
usedNames.add(name);
return name;
}
messages.forEach((msg, idx) => {
fullHtml += `<div class="header">`;
fullHtml += `<b>From:</b> ${escapeHtmlServer(msg.getFrom())}<br>`;
fullHtml += `<b>To:</b> ${escapeHtmlServer(msg.getTo())}<br>`;
fullHtml += `<b>Date:</b> ${Utilities.formatDate(msg.getDate(), timeZone, 'yyyy/MM/dd HH:mm:ss')}<br>`;
fullHtml += `</div>`;
fullHtml += `<div class="body">${msg.getBody()}</div>`;
if (idx < messages.length - 1) fullHtml += `<hr>`;
const attachments = msg.getAttachments();
attachments.forEach(att => {
const uniqueName = getUniqueName(att.getName());
blobs.push(att.copyBlob().setName(uniqueName));
});
});
fullHtml += `</body></html>`;
const pdfName = getUniqueName(`${cleanSubject}.pdf`);
const pdfBlob = Utilities.newBlob(fullHtml, 'text/html', `${cleanSubject}.html`)
.getAs('application/pdf')
.setName(pdfName);
blobs.unshift(pdfBlob);
const zipBlob = Utilities.zip(blobs, `${baseName}.zip`);
const base64Data = Utilities.base64Encode(zipBlob.getBytes());
return {
success: true,
fileName: `${baseName}.zip`,
base64: base64Data
};
} catch (e) {
console.error('exportEmailAsZip error:', e);
return { success: false, error: e.message };
}
}
function escapeHtmlServer(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
/**
* スレッドに対して正規の返信を送信する
*/
function replyToEmail(threadId, toAddress, _subjectText, bodyText, attachmentFiles) {
try {
const thread = GmailApp.getThreadById(threadId);
if (!thread) return { success: false, error: 'スレッドが見つかりません' };
const options = {
htmlBody: bodyText.replace(/\n/g, '<br>')
};
if (attachmentFiles && attachmentFiles.length > 0) {
options.attachments = attachmentFiles.map(file => {
const decoded = Utilities.base64Decode(file.base64);
return Utilities.newBlob(decoded, file.mimeType, file.name);
});
}
// 最新メッセージの送信者と toAddress を比較し、追加の宛先があれば cc に追加
const messages = thread.getMessages();
const latestMsg = messages[messages.length - 1];
const originalFrom = latestMsg.getFrom();
if (toAddress && toAddress.trim()) {
const targetEmails = toAddress.split(',').map(s => s.trim()).filter(Boolean);
const extraRecipients = targetEmails.filter(addr => !originalFrom.includes(addr));
if (extraRecipients.length > 0) {
options.cc = extraRecipients.join(', ');
}
}
// スレッドの文脈とヘッダー情報を維持して返信する
thread.reply(bodyText, options);
return { success: true };
} catch (e) {
console.error('replyToEmail error:', e);
return { success: false, error: e.message };
}
}
function getAttachment(messageId, attachmentIndex) {
try {
const msg = GmailApp.getMessageById(messageId);
if (!msg) return { success: false, error: 'メッセージが見つかりません' };
const attachments = msg.getAttachments();
if (attachmentIndex < 0 || attachmentIndex >= attachments.length) {
return { success: false, error: '添付ファイルが見つかりません' };
}
const att = attachments[attachmentIndex];
return {
success: true,
name: att.getName(),
mimeType: att.getContentType(),
base64: Utilities.base64Encode(att.getBytes())
};
} catch (e) {
console.error('getAttachment error:', e);
return { success: false, error: e.message };
}
}
function permanentlyDeleteMessage(threadId) {
try {
Gmail.Users.Threads.remove('me', threadId);
return { success: true };
} catch (e) {
const msg = e.message || '';
if (msg.includes('not found') || msg.includes('404') || msg.includes('NotFound')) {
return { success: true, note: 'Already deleted' };
}
console.error('permanentlyDeleteMessage error:', e);
return { success: false, error: msg };
}
}
</details>
<details><summary>appsscript.json(設定)</summary>
{
"timeZone": "Asia/Tokyo",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Gmail",
"version": "v1",
"serviceId": "gmail"
}
]
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "MYSELF"
}
}
</details>
<details><summary>Index.html(画面・約 1,830 行)</summary>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- PWA / 全画面アプリ化 -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mクイック">
<meta name="application-name" content="Mクイック">
<meta name="theme-color" content="#ffffff" id="metaThemeColor">
<link rel="apple-touch-icon" href="data:image/svg+xml;utf8,<svg xmlns='w3.org' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%230284c7'/><path d='M110 165l146 110 146-110' fill='none' stroke='white' stroke-width='32' stroke-linecap='round' stroke-linejoin='round'/><rect x='90' y='140' width='332' height='232' rx='28' fill='none' stroke='white' stroke-width='32'/><polygon points='280,210 220,320 270,320 240,410 330,290 270,290' fill='%23facc15'/></svg>">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='w3.org' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%230284c7'/><path d='M110 165l146 110 146-110' fill='none' stroke='white' stroke-width='32' stroke-linecap='round' stroke-linejoin='round'/><rect x='90' y='140' width='332' height='232' rx='28' fill='none' stroke='white' stroke-width='32'/><polygon points='280,210 220,320 270,320 240,410 330,290 270,290' fill='%23facc15'/></svg>">
<title>Mクイック - 超高速Gmailビューア</title>
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="Wed, 11 Jan 1984 05:00:00 GMT">
<style>
:root {
--text-main: #000000; --text-muted: #1e293b; --line-border: #cbd5e1;
--bg-header: #e2e8f0; --bg-btn: #f1f5f9; --bg-selected: #bae6fd;
--bg-body: #e2e8f0; --bg-container: #ffffff; --ui-height: 32px;
--sb-track: #f1f5f9; --sb-thumb: #94a3b8; --sb-thumb-hover: #64748b;
--row-height: 56px;
--card-bg: #ffffff; --card-border: #cbd5e1;
--card-radius: 8px; --card-gap: 8px;
--card-hover-bg: #e0f2fe; --card-hover-border: #0284c7;
--card-selected-bg: #7dd3fc; --card-selected-border: #0284c7;
--table-area-bg: #e2e8f0;
--text-accent: #0284c7; --text-accent-hover: #0369a1;
}
body.dark-mode {
--text-main: #f8fafc; --text-muted: #94a3b8; --line-border: #1e293b;
--bg-header: #0b111e; --bg-btn: #1e293b; --bg-selected: #112547;
--bg-body: #050811; --bg-container: #0b111e; --sb-track: #0b111e;
--sb-thumb: #1e293b; --sb-thumb-hover: #334155;
--card-bg: #111d33; --card-border: rgba(255, 255, 255, 0.08);
--card-radius: 8px; --card-gap: 8px;
--card-hover-bg: #162642; --card-hover-border: #2563eb;
--card-selected-bg: #112547; --card-selected-border: #38bdf8;
--table-area-bg: #0b111e;
--text-accent: #38bdf8; --text-accent-hover: #7dd3fc;
}
body.green-mode {
--text-main: #000000; --text-muted: #064e3b; --line-border: #86efac;
--bg-header: #dcfce7; --bg-btn: #bbf7d0; --bg-selected: #86efac;
--bg-body: #d1fae5; --bg-container: #ecfdf5; --sb-track: #d1fae5;
--sb-thumb: #34d399; --sb-thumb-hover: #10b981;
--ui-height: 32px; --row-height: 56px;
--card-bg: #ffffff; --card-border: #86efac;
--card-radius: 8px; --card-gap: 8px;
--card-hover-bg: #a7f3d0; --card-hover-border: #047857;
--card-selected-bg: #6ee7b7; --card-selected-border: #047857;
--table-area-bg: #d1fae5;
--text-accent: #047857; --text-accent-hover: #064e3b;
}
body.green-mode select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%23047857'/></svg>"); background-color: #ecfdf5; }
body.green-mode .top-settings-menu-item:hover { background: #bbf7d0; color: #064e3b; }
body.green-mode button.count-btn.active { background: #047857; color: #ffffff; border-color: #047857; font-weight: 700; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background-color: var(--bg-body); margin: 0; padding: 20px; color: var(--text-main);
transition: background-color 0.2s, color 0.2s;
}
.container {
max-width: 1300px; margin: 0 auto; background: var(--bg-container);
border: 1px solid var(--line-border); border-radius: 8px;
display: flex; flex-direction: column; height: 92vh; overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.02);
transition: background-color 0.2s, border-color 0.2s; position: relative;
}
.top-bar {
padding: 10px 20px; border-bottom: 1px solid var(--line-border);
display: flex; align-items: center; justify-content: space-between; background: var(--bg-container);
transition: background-color 0.2s, border-color 0.2s;
}
.title-area { display: flex; align-items: center; gap: 10px; }
h1, h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-main); white-space: nowrap; user-select: none; }
.sync-indicator {
display: none; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600;
color: #0369a1; background: #e0f2fe; border: 1px solid #bae6fd;
padding: 2px 8px; border-radius: 12px; white-space: nowrap;
}
body.dark-mode .sync-indicator { color: #38bdf8; background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.3); }
.controls { display: flex; align-items: center; gap: 12px; }
.btn-group { display: flex; align-items: center; gap: 4px; }
.btn-group span { font-size: 12px; font-weight: 600; margin-right: 2px; white-space: nowrap; color: var(--text-muted); }
button.count-btn, select.menu-select, .search-box input {
height: var(--ui-height); box-sizing: border-box; border: 1px solid var(--line-border); border-radius: 6px; font-size: 12px; font-weight: 500; background: var(--bg-container); color: var(--text-main); padding: 0 10px; outline: none; transition: all 0.15s;
}
button.count-btn { cursor: pointer; background: var(--bg-btn); border-color: var(--line-border); }
button.count-btn:hover { background: var(--line-border); }
button.count-btn.active { background: #38bdf8; color: #0f172a; border-color: #38bdf8; font-weight: 700; }
button:disabled, select:disabled, input:disabled { opacity: 0.5; cursor: not-allowed !important; }
body:not(.dark-mode) select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%23475569'/></svg>"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 22px; appearance: none; }
body.dark-mode select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%2394a3b8'/></svg>"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 22px; appearance: none; }
.search-box { position: relative; display: flex; align-items: center; }
.search-box input { padding: 0 24px 0 8px; width: 160px; }
.search-box button { position: absolute; right: 6px; background: none; border: none; font-size: 18px; font-weight: 500; color: var(--text-muted); cursor: pointer; display: none; padding: 0; }
.table-wrapper { overflow-y: auto; overflow-x: hidden; flex-grow: 1; outline: none; position: relative; background: var(--table-area-bg); padding: 8px 16px; }
#mainTable { width: 100%; border-collapse: separate; border-spacing: 0 var(--card-gap); table-layout: fixed; }
#mainTable thead th { position: sticky; top: 0; z-index: 10; background: var(--table-area-bg); font-size: 12px; color: var(--text-muted); padding: 4px 14px; border: none; text-align: left; font-weight: 600; letter-spacing: 0.02em; }
#mainTable tbody tr { height: var(--row-height); cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; }
#mainTable td { padding: 0 14px; font-size: 13px; color: var(--text-main); overflow: hidden; font-weight: 400; vertical-align: middle; background-color: var(--card-bg); border-top: 1px solid var(--card-border); border-bottom: 1px solid var(--card-border); transition: background-color 0.15s ease, border-color 0.15s ease; }
#mainTable td:first-child { border-left: 1px solid var(--card-border); border-top-left-radius: var(--card-radius); border-bottom-left-radius: var(--card-radius); }
#mainTable td:last-child { border-right: 1px solid var(--card-border); border-top-right-radius: var(--card-radius); border-bottom-right-radius: var(--card-radius); }
#mainTable tbody tr.processing { opacity: 0.4; pointer-events: none; }
#mainTable tbody tr:hover td { background-color: var(--card-hover-bg) !important; border-color: var(--card-hover-border) !important; }
#mainTable tr.selected td,
#mainTable tbody tr.selected td { background-color: var(--card-selected-bg) !important; border-color: var(--card-selected-border) !important; font-weight: 600; color: var(--text-main) !important; }
#mainTable tr.selected td:first-child,
#mainTable tbody tr.selected td:first-child { border-left: 6px solid var(--card-selected-border) !important; padding-left: 10px !important; }
#mainTable tr.selected td:last-child,
#mainTable tbody tr.selected td:last-child { border-right: 2px solid var(--card-selected-border) !important; }
#mainTable tr.selected td,
#mainTable tbody tr.selected td { border-top: 2px solid var(--card-selected-border) !important; border-bottom: 2px solid var(--card-selected-border) !important; }
#mainTable tr.selected .subject-text { color: var(--text-main) !important; font-weight: 600; }
#mainTable tr.selected .col-from { color: var(--text-main) !important; font-weight: 700; }
#mainTable tr.selected .clickable { color: var(--text-main) !important; }
#mainTable tr.selected .clickable:hover { color: var(--text-accent-hover) !important; }
tr.unread td { font-weight: 600; }
tr.unread .subject-text { font-weight: 600; color: var(--text-accent) !important; }
tr.selected.unread .subject-text { color: var(--text-accent) !important; font-weight: 700; }
.clickable { cursor: pointer; }
.clickable:hover { text-decoration: underline; color: var(--text-accent-hover) !important; }
.col-date { width: 90px; text-align: center; color: var(--text-muted); font-size: 12px; }
.col-from { width: 160px; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.col-subject { width: auto; position: relative; }
.subject-wrapper { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; line-height: 1.35; max-height: 2.7em; word-break: break-word; }
.subject-text { font-weight: 400; color: var(--text-main); }
.label-container {
display: inline-flex; align-items: center; gap: 4px; position: absolute;
right: 14px; top: 50%; transform: translateY(-50%);
background: var(--bg-container); padding: 2px 6px; border-radius: 4px;
box-shadow: -4px 0 12px var(--bg-container);
z-index: 5; opacity: 0; transition: opacity 0.1s ease; pointer-events: none;
}
#mainTable tbody tr:hover .label-container { opacity: 1; pointer-events: auto; }
.label-badge { display: inline-flex; align-items: center; padding: 1px 6px; font-size: 10.5px; background: var(--bg-btn) !important; border: 1px solid var(--line-border); border-radius: 4px; color: var(--text-main) !important; font-weight: 600; white-space: nowrap; }
.remove-badge-btn { margin-left: 4px; cursor: pointer; font-weight: 700; color: var(--text-muted); padding: 0 1px; }
.remove-badge-btn:hover { color: #ef4444; }
.col-action { width: 155px; text-align: center; border-right: none; padding-left: 2px !important; padding-right: 2px !important; }
.action-container { display: flex; align-items: center; justify-content: center; gap: 4px; width: 100%; flex-wrap: nowrap !important; white-space: nowrap !important; }
.icon-btn { background: rgba(148, 163, 184, 0.08) !important; border: 1px solid var(--card-border) !important; font-size: 13px; cursor: pointer; margin: 0; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s; width: 28px; height: 28px; box-sizing: border-box; flex-shrink: 0 !important; color: var(--text-main); }
.icon-btn:hover { background: var(--bg-btn) !important; border-color: var(--text-muted) !important; transform: translateY(-1px); }
.top-settings-menu {
position: absolute; top: 48px; right: 20px;
background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
z-index: 1500; min-width: 180px; padding: 6px 0; display: none; flex-direction: column;
}
.top-settings-menu.show { display: flex; }
.top-settings-menu-item { padding: 8px 16px; font-size: 13px; color: var(--text-main); cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 10px; }
.top-settings-menu-item:hover { background: var(--bg-btn); color: #38bdf8; }
.dropdown-divider { height: 1px; background: var(--line-border); margin: 4px 0; }
.custom-dropdown-menu {
position: fixed; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 8px;
box-shadow: 0 10px 20px rgba(0,0,0,0.2); z-index: 1000; min-width: 150px; padding: 4px 0; max-height: 50vh; overflow-y: auto;
}
.dropdown-item { padding: 6px 12px; font-size: 12.5px; color: var(--text-main); cursor: pointer; font-weight: 500; }
.dropdown-item:hover { background: var(--bg-btn); color: #38bdf8; }
.date-main { font-weight: 500; display: block; font-size: 12px; color: var(--text-main); pointer-events: none; }
.date-sub { font-size: 10.5px; color: var(--text-muted); font-weight: 500; display: block; margin-top: 1px; pointer-events: none; }
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; z-index: 2000; }
.modal-content { background: #1e293b; color: #f8fafc; width: 85%; max-width: 840px; max-height: 82vh; border-radius: 12px; padding: 24px; overflow-y: auto; position: relative; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3); }
.modal-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 2px solid #38bdf8; padding-bottom: 10px; margin-bottom: 16px; }
.modal-title { font-size: 17px; font-weight: 600; margin: 0; color: #f8fafc; display: flex; align-items: center; gap: 6px; }
.close-btn { padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; color: #f8fafc; border: 1px solid #475569; background: #334155; border-radius: 6px; transition: background 0.15s; }
.close-btn:hover { background: #475569; }
.email-modal-container { width: 90%; max-width: 950px; height: 85vh; display: flex; flex-direction: column; padding: 18px 24px; background: var(--bg-container); color: var(--text-main); border: 1px solid var(--line-border); }
.email-modal-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 12px; border-bottom: 1px solid var(--line-border); }
.email-modal-title { font-size: 16px; font-weight: 600; color: var(--text-main); margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
.email-modal-body { flex: 1; overflow-y: auto; padding-top: 14px; display: flex; flex-direction: column; }
.msg-card { border: 1px solid var(--line-border); border-radius: 8px; background: var(--bg-header); padding: 12px 16px; margin-bottom: 14px; transition: all 0.2s; }
.msg-meta { font-size: 12px; color: var(--text-muted); line-height: 1.6; margin-bottom: 10px; border-bottom: 1px dashed var(--line-border); padding-bottom: 8px; transition: all 0.2s; overflow: hidden; }
.msg-meta strong { color: var(--text-main); }
.msg-attachments { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.attachment-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 4px; background: var(--bg-btn); border: 1px solid var(--line-border); font-size: 11px; color: var(--text-main); }
.clickable-attachment { cursor: pointer; transition: filter 0.15s, color 0.15s; }
.clickable-attachment:hover { filter: brightness(0.9); color: #0284c7 !important; border-color: #7dd3fc; }
body.dark-mode .clickable-attachment:hover { filter: brightness(1.2); color: #38bdf8 !important; }
.email-frame { width: 100%; border: none; background: #ffffff; border-radius: 4px; min-height: 180px; }
.open-ext-btn { padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; color: #0284c7; background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; }
details.recipients-box { margin: 3px 0; }
details.recipients-box summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; user-select: none; outline: none; }
details.recipients-box summary::-webkit-details-marker { display: none; }
.recipients-summary-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); font-size: 12px; flex: 1; }
.recipients-badge { font-size: 11px; color: #38bdf8; background: rgba(56, 189, 248, 0.12); border: 1px solid rgba(56, 189, 248, 0.3); padding: 1px 7px; border-radius: 4px; white-space: nowrap; }
.recipients-full-list { margin-top: 6px; padding: 8px 10px; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 6px; max-height: 120px; overflow-y: auto; font-size: 11.5px; line-height: 1.5; word-break: break-all; }
.m-section { margin-bottom: 24px; }
.m-h2 { font-size: 14.5px; font-weight: 700; margin-top: 22px; margin-bottom: 10px; padding: 4px 8px; border-left: 4px solid #38bdf8; background: rgba(56, 189, 248, 0.08); color: #38bdf8; border-radius: 0 4px 4px 0; }
.m-p { font-size: 13px; color: #cbd5e1; line-height: 1.6; margin: 8px 0; font-weight: 400; }
.m-ul { font-size: 13px; color: #cbd5e1; line-height: 1.7; padding-left: 20px; margin: 6px 0 12px 0; font-weight: 400; }
.m-ul li { margin-bottom: 6px; }
.m-ul strong { font-weight: 600; color: #f8fafc; }
.m-tag { display: inline-block; padding: 1px 6px; font-size: 11px; border-radius: 4px; background: #334155; border: 1px solid #475569; color: #38bdf8; font-weight: 600; margin: 0 2px; }
.m-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; }
.m-table th { background: #0f172a !important; color: #38bdf8 !important; text-align: left; padding: 10px 12px; border: 1px solid #334155 !important; font-weight: 600; }
.m-table td { background: #0f172a !important; color: #f8fafc !important; border: 1px solid #334155 !important; padding: 10px 12px; vertical-align: middle; border-radius: 0 !important; font-size: 13px; line-height: 1.6; }
.m-table tbody tr:nth-child(even) td { background: #172033 !important; }
.m-table td strong { color: #38bdf8 !important; }
.m-callout { background: rgba(56, 189, 248, 0.05); border: 1px solid rgba(56, 189, 248, 0.2); border-radius: 6px; padding: 10px 14px; margin: 12px 0; font-size: 12.5px; color: #e2e8f0; line-height: 1.5; }
- { scrollbar-width: thin; scrollbar-color: var(--sb-thumb) var(--sb-track); }
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: var(--sb-track); border-radius: 6px; }
::-webkit-scrollbar-thumb { background: var(--sb-thumb); border-radius: 6px; border: 3px solid var(--sb-track); }
::-webkit-scrollbar-thumb:hover { background: var(--sb-thumb-hover); }
.btn-success { background: #10b981 !important; color: white !important; border-color: #059669 !important; }
.btn-success:hover { background: #059669 !important; }
.btn-primary { background: #38bdf8 !important; color: #0f172a !important; border-color: #0284c7 !important; font-weight: bold; }
.btn-primary:hover { filter: brightness(0.9); }
#replyFormContainer { display: none; border-top: 1px solid var(--line-border); padding: 16px 0 0 0; margin-top: 8px; }
.reply-to-input { flex: 1; height: 30px; padding: 0 8px; border: 1px solid var(--line-border); border-radius: 4px; font-size: 13px; background: var(--bg-container); color: var(--text-main); outline: none; transition: border-color 0.2s; font-family: inherit; }
.reply-to-input:focus { border-color: #38bdf8; }
.reply-textarea { width: 100%; box-sizing: border-box; height: 150px; padding: 12px; border: 1px solid var(--line-border); border-radius: 6px; resize: vertical; font-family: inherit; font-size: 13.5px; background: var(--bg-container); color: var(--text-main); outline: none; transition: border-color 0.2s; }
.reply-textarea:focus { border-color: #38bdf8; }
.form-label { width: 55px; font-size: 13px; font-weight: 600; color: var(--text-main); text-align: right; padding-right: 8px; display: inline-block; }
/* ==========================================
スマートフォン最適化レスポンシブスタイル
========================================== */
@media (max-width: 640px) {
body {
padding: 0 !important;
margin: 0 !important;
height: 100vh !important;
height: 100dvh !important;
overflow: hidden !important;
}
.container {
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
height: 100dvh !important;
border: none !important;
border-radius: 0 !important;
margin: 0 !important;
}
.top-bar {
padding: 8px 10px !important;
flex-direction: column !important;
gap: 8px !important;
align-items: stretch !important;
}
.title-area {
width: 100% !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
.top-bar .controls {
width: 100% !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
justify-content: space-between !important;
}
.top-bar .controls .menu-select {
width: 86px !important;
min-width: 86px !important;
padding-left: 6px !important;
padding-right: 18px !important;
font-size: 11.5px !important;
}
.top-bar .controls .search-box {
flex: 1 1 auto !important;
min-width: 0 !important;
}
.top-bar .controls .search-box input {
width: 100% !important;
font-size: 11.5px !important;
}
.top-bar .controls .btn-group {
flex: 0 0 auto !important;
gap: 2px !important;
}
.top-bar .controls .btn-group span {
display: none !important;
}
.top-bar .controls button.count-btn {
padding: 0 6px !important;
font-size: 11px !important;
}
/* スワイプアクション用アニメーション */
.swipe-container {
position: relative;
overflow: hidden;
}
.swipe-action-bg {
position: absolute;
top: 0; bottom: 0; left: 0; right: 0;
display: flex;
align-items: center;
padding: 0 20px;
font-weight: 700;
font-size: 14px;
color: white;
border-radius: 8px;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
}
.swipe-action-archive {
background: #10b981;
justify-content: flex-start;
}
.swipe-action-delete {
background: #ef4444;
justify-content: flex-end;
}
#mainTable tbody tr:not(.virtual-spacer) {
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.15s;
will-change: transform;
}
/* テーブルのスマホカード化 */
.table-wrapper {
padding: 4px 8px !important;
}
#mainTable {
display: block !important;
width: 100% !important;
table-layout: auto !important;
border-spacing: 0 !important;
}
#mainTable thead {
display: none !important;
}
#mainTable tbody {
display: block !important;
width: 100% !important;
}
.virtual-spacer {
display: block !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
}
.virtual-spacer td {
display: block !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
#mainTable tbody tr:not(.virtual-spacer) {
display: block !important;
height: 114px !important;
box-sizing: border-box !important;
padding: 8px 10px !important;
margin-bottom: 8px !important;
border-radius: 8px !important;
border: 1px solid var(--card-border) !important;
background-color: var(--card-bg) !important;
position: relative !important;
}
#mainTable tbody tr:not(.virtual-spacer) td {
display: block !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
#mainTable tbody tr:not(.virtual-spacer) td:first-child,
#mainTable tbody tr:not(.virtual-spacer) td:last-child {
border-radius: 0 !important;
border: none !important;
}
.col-from {
display: block !important;
font-size: 13.5px !important;
font-weight: 700 !important;
color: var(--text-main) !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
margin-right: 80px !important;
line-height: 18px !important;
}
.col-date {
position: absolute !important;
top: 8px !important;
right: 10px !important;
width: auto !important;
font-size: 11px !important;
color: var(--text-muted) !important;
text-align: right !important;
white-space: nowrap !important;
line-height: 18px !important;
}
.date-main {
font-size: 11px !important;
}
.date-sub {
display: none !important;
}
.col-subject {
display: block !important;
margin-top: 4px !important;
width: 100% !important;
font-size: 12.5px !important;
line-height: 1.35 !important;
color: var(--text-main) !important;
}
.subject-wrapper {
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
max-height: 2.7em !important;
}
.col-action {
position: absolute !important;
bottom: 6px !important;
right: 10px !important;
left: 10px !important;
width: auto !important;
padding-top: 4px !important;
border-top: 1px dashed var(--line-border) !important;
}
.action-container {
display: flex !important;
justify-content: flex-end !important;
gap: 10px !important;
}
.action-container button.icon-btn {
width: 30px !important;
height: 26px !important;
font-size: 14px !important;
background: var(--bg-btn) !important;
border: 1px solid var(--line-border) !important;
border-radius: 4px !important;
}
/* 選択時ハイライト(スマホ) */
#mainTable tbody tr.selected:not(.virtual-spacer) {
background-color: var(--card-selected-bg) !important;
border-color: var(--card-selected-border) !important;
}
/* モーダルのスマホ最適化 */
.modal-content {
width: 95% !important;
max-width: 95% !important;
max-height: 92vh !important;
padding: 12px !important;
border-radius: 8px !important;
}
.email-modal-header {
flex-direction: column !important;
align-items: stretch !important;
gap: 8px !important;
}
.email-modal-header > div {
flex-wrap: wrap !important;
justify-content: flex-end !important;
}
.email-modal-title {
font-size: 14px !important;
}
}
</style>
</head>
<body>
<div class="container">
<header class="top-bar" role="banner">
<div class="title-area">
<h1 id="pageTitle">Gmail 一覧</h1>
<span id="syncBadge" class="sync-indicator">🔄 同期中...</span>
</div>
<div class="controls">
<div class="btn-group">
<label for="viewSelect" style="display:flex;align-items:center;">種別:</label>
<select id="viewSelect" class="menu-select" onchange="changeView(this.value)" aria-label="メール種別切り替え">
<option value="inbox" selected>受信</option>
<option value="sent">送信</option>
<option value="archive">アーカイブ</option>
<option value="all">全て</option>
<option value="trash">ゴミ箱</option>
<option disabled id="labelDivider">----------</option>
</select>
</div>
<div class="search-box">
<input type="text" id="keywordInput" placeholder="一覧から絞り込み..." aria-label="メール絞り込みキーワード">
<button id="clearBtn" onclick="clearSearch()" aria-label="検索クリア">×</button>
</div>
<div class="btn-group" role="group" aria-label="表示件数切り替え">
<button id="cntBtn50" onclick="changeCount(50, this)" class="count-btn active" aria-label="50件表示">50</button>
<button id="cntBtn100" onclick="changeCount(100, this)" class="count-btn" aria-label="100件表示">100</button>
<button id="cntBtnAll" onclick="changeCount('all', this)" class="count-btn" aria-label="全件表示">全</button>
</div>
<button id="mainSettingsBtn" onclick="toggleSettingsMenu(event)" class="icon-btn" title="設定とメニュー" aria-label="設定とメニュー">⚙️</button>
</div>
</header>
<div id="topSettingsMenu" class="top-settings-menu" role="menu" aria-label="設定メニュー">
<div class="top-settings-menu-item" onclick="handleMenuRefresh()" role="menuitem">
<span class="top-settings-menu-icon">🔄</span> 最新情報を取得
</div>
<div class="top-settings-menu-item" onclick="handleCycleTheme()" role="menuitem">
<span class="top-settings-menu-icon" id="themeMenuIcon">☀️</span> <span id="themeMenuText">ホワイトモード</span>
</div>
<div class="dropdown-divider"></div>
<div class="top-settings-menu-item" onclick="handleMenuManual()" role="menuitem">
<span class="top-settings-menu-icon">❓</span> 使い方マニュアル
</div>
</div>
<main class="table-wrapper" id="tableArea" tabindex="0" onscroll="onScrollThrottled()" role="main">
<table id="mainTable">
<thead>
<tr>
<th class="col-date">日付</th>
<th class="col-from">送付元・宛先</th>
<th class="col-subject">件名</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody id="listBody">
</tbody>
</table>
</main>
</div>
<!-- メール閲覧モーダル -->
<div id="emailViewModal" class="modal-overlay" onclick="closeEmailModal()">
<div class="modal-content email-modal-container" onclick="event.stopPropagation()">
<div class="email-modal-header">
<h3 class="email-modal-title" id="viewModalSubject">読み込み中...</h3>
<!-- ボタン群: 返信、Gmail、印刷、削除、閉じる の順 -->
<div style="display: flex; gap: 8px; align-items: center;">
<button id="replyBtn" class="open-ext-btn btn-success" style="display: none;" onclick="toggleReplyForm()">返信</button>
<a id="viewModalExtLink" href="#" target="_blank" class="open-ext-btn">Gmail</a>
<button id="printBtn" class="open-ext-btn" onclick="printCurrentEmail()">印刷</button>
<button id="deleteEmailBtn" class="open-ext-btn" onclick="deleteCurrentEmail(event)" title="削除" style="color: #ef4444; background: #fee2e2; border-color: #fecaca; font-size: 13px; padding: 4px 10px;">🗑️</button>
<button class="close-btn" onclick="closeEmailModal()">閉じる</button>
</div>
</div>
<div class="email-modal-body" id="viewModalScrollArea">
<div id="viewModalContent">
<div style="text-align: center; padding: 40px; color: var(--text-muted);">本文を読み込んでいます...</div>
</div>
<!-- 返信フォーム -->
<div id="replyFormContainer">
<div style="margin-bottom: 12px; font-weight: 600; font-size: 14px; color: #38bdf8;">
◆ 返信の作成
</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;">
<div style="display: flex; align-items: center;">
<span class="form-label">宛先(To):</span>
<input type="text" id="replyToInput" class="reply-to-input" placeholder="宛先メールアドレスを入力してください">
</div>
<div style="display: flex; align-items: center;">
<span class="form-label">件名:</span>
<input type="text" id="replySubjectInput" class="reply-to-input" placeholder="件名を入力してください">
</div>
</div>
<textarea id="replyBody" class="reply-textarea" placeholder="返信内容を入力してください..."></textarea>
<div style="margin-top: 12px; display: flex; align-items: center; justify-content: space-between;">
<input type="file" id="replyAttachments" multiple style="font-size: 12px; color: var(--text-muted); cursor: pointer;">
<div style="display: flex; gap: 8px;">
<button onclick="toggleReplyForm()" class="close-btn">キャンセル</button>
<button id="sendReplyBtn" onclick="sendReply()" class="open-ext-btn btn-primary">送信する</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- マニュアルモーダル -->
<div id="manualModal" class="modal-overlay" onclick="closeManualModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
1 like
Mentions
「これ、軽量モデルのフラッシュ君で書いたの…?」 ── 秀GASマネージャーを持たせたら、最速AIがネイティブ級スマホアプリを爆速生成する怪物になった話 はじめに ―― 「これ、スマホで使えたら最強じゃないか?」 前回の記事で、自作のGmail爆速ビューア『Mクイック』をブラウザゼロで修復し、0.5秒の連打にも完全追従する怪物に仕立て上げた話を公開しました。 - 本番WebアプリをAIにブラウザゼロで直させたら、0.5秒の連打にも完全追従する怪物になった話 ―― 秀GASマネージャー実戦証明と『からくりポケット』の同梱公開 PCのブラウザで叩くMクイックは、まさに快適そのもの。数千件のメールがIndexedDBから瞬時に立ち上がり、キーボード操作でサクサク処理できる相棒になりました。 ですが、使い込んでいるうちに、ごく自然な欲求が湧き上がってきたのです。 「出先や移動中に、スマホでもこの速度でGmailを確認できたら、どれだけ助かるだろうか?」 早速スマートフォンでアクセスしてみました。 ……しかし、現実は甘くありませんでした。 !スマホ表示が崩れていた初期状態 PC用の4列テーブル(日付・差出人・件名・操作)を無理やりスマホの画面幅(約390px)に詰め込んだため、ヘッダーのボタンは画面外へ吹き飛び、件名列は押し潰され、とても実用に耐える状態ではなかったのです。 「やっぱりGASのWebアプリでスマホアプリなんて無理なのか……?」 普通ならここで諦めるか、「まあPC専用ツールだから」と割り切るところです。 しかし、私には 秀GASマネージャー と、頼れる相棒 Gemini Flash(フラッシュ君) がいました。 ここから、「軽量モデル×ガスマネージャー」が起こす、前代未聞のスマホアプリ開発劇が始まったのです。 !スマホになった! --- 爆誕した『Mクイック』スマホ版 ―― ネイティブアプリと見分けがつかない3大機能 結論から申し上げます。完成したものは、「App Storeにあるネイティブアプリそのもの」 でした。 GAS特有のもっさり感や「Webページを見ている感」は完全に消え去っています。 !スマホ版Mクイックの完成画面(ホワイト・グリーン・ダークの3色テーマ) 1. PWA(Progressive Web App)による「全画面アプリ化」 スマホのブラウザ(SafariやChrome)で開き、メニューから 「ホーム画面に追加」 を押すだけ。 それだけで、ブラウザのアドレスバーや戻るボタンが完全に消え去り、全画面スタンドアローンアプリとして起動します(※iPhoneでは専用の稲妻アイコン、AndroidではGAS仕様により親しみのある青いGoogleの「G」アイコンが配置されます)。 高価なMacも、年間数万円のApple Developer登録も、厳しいアプリ審査も一切不要。 Googleの無料インフラだけで、完全なスマホアプリが手元で動くのです。 2. Gmail公式準拠の「左右スワイプアクション」 スマートフォンで小さなボタンを親指で狙うのはストレスです。 そこで、メールカードを左右に指先でスワイプして処理できるようにしました。 - 👉 右へスワイプ(緑): 指を離すとその場で 「アーカイブ」 - 👈 左へスワイプ(赤): 指を離すとその場で 「ゴミ箱(削除)」 途中で指を戻せばバネのようにシュッと元の位置に戻る、滑らかなタッチアニメーションを実装しています。 3. 触覚フィードバック(Haptic微振動) スワイプが閾値を超えた瞬間や、ボタンをタップした瞬間に、スマホ本体が 「コトッ」「トッ」と心地よく微振動 します。 わずか数行の Vibration API ですが、これが入るだけで「Webページを操作している感覚」が脳内から消え、本物のネイティブアプリの質感に化けました。 --- Mクイック のコード(自分でメールビューアを作りたい方へ) この節では当初、Mクイック の写しをワンクリックで作れるリンクを置いていました。写しを配る形はやめて、コードをそのまま載せることにしました(2026-11-05)。 自分で Gmail のビューアを作ってみたい方の参考になればと思います。Apps Script の新しいプロジェクトに、下の 3 つのファイルを作って貼り、ウェブアプリとしてデプロイすれば動きます。 作るときに気をつけたい所を 2 つだけ書いておきます。 - メール本文は sandbox 付きの iframe で表示しています。 getBody() で取れる HTML は、Gmail の画面と違ってスクリプトが取り除かれていません。Index.html の EMAIL_SAFE_HEAD の周りを見てください。 - 添付ファイル名のように送り主が決める文字を onclick="…" に入れるときは、JS と HTML の両方の処理をしています。 Index.html の escapeJsStr です。 載せているのは、この 2 つを足した 11/05 の版です。 <details><summary>コード.gs(サーバー側・373 行)</summary> /** * Webアプリケーションのエントリポイント */ function doGet() { return HtmlService.createHtmlOutputFromFile('Index') .setTitle('Gmail 一覧ダッシュボード') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) .addMetaTag('viewport', 'width=device-width, initial-scale=1'); } /** * ユーザー作成のラベル一覧を取得 */ function getUserLabels() { try { const labels = GmailApp.getUserLabels(); return labels.map(label => label.getName()); } catch (e) { console.error('getUserLabels error:', e); return []; } } /** * 条件に応じたメール一覧を取得 (N+1問題解消・添付走査の最適化版) */ function getEmailList(count, mode, _fetchDetails) { try { let query = ''; if (mode === 'inbox') { query = 'in:inbox'; } else if (mode === 'sent') { query = 'in:sent'; } else if (mode === 'trash') { query = 'in:trash'; } else if (mode === 'archive') { query = '-in:inbox -in:sent -in:trash'; } else if (mode === 'all') { query = '-in:trash'; } else { query = 'label:"' + mode + '" -in:trash'; } const limit = (count === 'all' || typeof count !== 'number') ? 300 : count; const threads = GmailApp.search(query, 0, Math.min(limit, 500)); if (threads.length === 0) return []; const messagesPerThread = GmailApp.getMessagesForThreads(threads); const result = []; const userEmail = Session.getActiveUser().getEmail(); const timeZone = Session.getScriptTimeZone(); threads.forEach((thread, index) => { const messages = messagesPerThread[index]; if (!messages || messages.length === 0) return; const latestMsg = messages[messages.length - 1]; const dateObj = latestMsg.getDate(); const dateStr = Utilities.formatDate(dateObj, timeZone, 'yyyy/MM/dd'); const timeStr = Utilities.formatDate(dateObj, timeZone, 'HH:mm'); const fromRaw = (mode === 'sent') ? latestMsg.getTo() : latestMsg.getFrom(); const fromMatch = fromRaw.match(/^(.*?)(?:<.*?>)?$/); const fromClean = (fromMatch && fromMatch[1].trim()) ? fromMatch[1].replace(/"/g, '').trim() : fromRaw; let hasAttachment = false; for (let i = messages.length - 1; i >= 0; i--) { try { if (messages[i].getAttachments({ includeInlineImages: false }).length > 0) { hasAttachment = true; break; } } catch (e) { break; } } const threadLabels = thread.getLabels().map(l => l.getName()); const labelIds = []; if (thread.isInInbox()) labelIds.push('INBOX'); if (thread.isInTrash()) labelIds.push('TRASH'); if (mode === 'sent' || messages.some(m => m.getFrom().includes(userEmail))) { labelIds.push('SENT'); } result.push({ id: thread.getId(), subject: thread.getFirstMessageSubject() || '(件名なし)', from: fromClean, date: dateStr, time: timeStr, isUnread: thread.isUnread(), hasAttachment: hasAttachment, attachmentNames: [], labels: threadLabels, labelIds: labelIds }); }); return result; } catch (e) { console.error('getEmailList error:', e); throw new Error(e.message); } } /** * スレッドの詳細を取得し、既読化する */ function getThreadDetails(threadId) { try { const thread = GmailApp.getThreadById(threadId); if (!thread) return { success: false, error: 'スレッドが見つかりません' }; if (thread.isUnread()) { thread.markRead(); } const messages = thread.getMessages(); const timeZone = Session.getScriptTimeZone(); const details = messages.map(msg => { const msgId = msg.getId(); const body = msg.getBody(); const plainBody = body ? '' : msg.getPlainBody(); const attachments = msg.getAttachments(); return { id: msgId, from: msg.getFrom(), to: msg.getTo(), cc: msg.getCc(), date: Utilities.formatDate(msg.getDate(), timeZone, 'yyyy/MM/dd HH:mm:ss'), body: body, plainBody: plainBody, attachments: attachments.map((att, attIdx) => ({ msgId: msgId, attIndex: attIdx, name: att.getName(), size: Math.round(att.getSize() / 1024) + ' KB', mimeType: att.getContentType() })) }; }); return { success: true, subject: thread.getFirstMessageSubject() || '(件名なし)', messages: details }; } catch (e) { console.error('getThreadDetails error:', e); return { success: false, error: e.message }; } } /** * ラベル付与とアーカイブ */ function updateLabelsAndArchive(threadId, labelName, isInboxMode) { try { const thread = GmailApp.getThreadById(threadId); if (!thread) return { success: false, error: 'スレッドが見つかりません' }; if (labelName && labelName !== '__archive_only__') { let label = GmailApp.getUserLabelByName(labelName); if (!label) { label = GmailApp.createLabel(labelName); } thread.addLabel(label); } if (isInboxMode) { thread.moveToArchive(); } return { success: true }; } catch (e) { return { success: false, error: e.message }; } } /** * スレッドからラベルを解除 */ function removeLabelFromThread(threadId, labelName) { try { const thread = GmailApp.getThreadById(threadId); if (!thread) return { success: false, error: 'スレッドが見つかりません' }; const label = GmailApp.getUserLabelByName(labelName); if (label) { thread.removeLabel(label); } return { success: true }; } catch (e) { return { success: false, error: e.message }; } } /** * メールをゴミ箱へ移動 */ /** * ゴミ箱から受信トレイへ復元 */ /** * ZIPエクスポート */ function exportEmailAsZip(threadId) { try { const thread = GmailApp.getThreadById(threadId); if (!thread) return { success: false, error: 'スレッドが見つかりません' }; const messages = thread.getMessages(); const firstMsg = messages[0]; const timeZone = Session.getScriptTimeZone(); const userEmail = Session.getActiveUser().getEmail(); const timeStamp = Utilities.formatDate(firstMsg.getDate(), timeZone, 'yyyyMMdd_HHmm'); const subject = thread.getFirstMessageSubject() || '無題'; const cleanSubject = (subject.replace(/[\/\\?%*:|"<>]/g, '_').trim() || '無題').substring(0, 30); const isSent = firstMsg.getFrom().includes(userEmail); const rawTarget = isSent ? firstMsg.getTo() : firstMsg.getFrom(); const match = rawTarget.match(/^(.*?)(?:<.*?>)?$/); const targetName = (match && match[1].trim()) ? match[1].replace(/["']/g, '').trim() : rawTarget; const cleanTarget = (targetName.replace(/[\/\\?%*:|"<>]/g, '_').trim() || '相手先').substring(0, 15); const baseName = `${timeStamp}_${cleanSubject}_${cleanTarget}`; let fullHtml = `<html><head><meta charset="utf-8"><style>body{font-family:sans-serif;padding:20px;} h2{border-bottom:1px solid #ccc;padding-bottom:8px;} .header{color:#555;font-size:13px;margin-bottom:20px;line-height:1.6;} hr{border:none;border-top:1px solid #eee;margin:20px 0;}</style></head><body>`; fullHtml += `<h2>${escapeHtmlServer(subject)}</h2>`; const blobs = []; const usedNames = new Set(); function getUniqueName(fileName) { let name = fileName; let counter = 1; const dotIndex = name.lastIndexOf('.'); const base = dotIndex !== -1 ? name.substring(0, dotIndex) : name; const ext = dotIndex !== -1 ? name.substring(dotIndex) : ''; while (usedNames.has(name)) { name = `${base}(${counter})${ext}`; counter++; } usedNames.add(name); return name; } messages.forEach((msg, idx) => { fullHtml += `<div class="header">`; fullHtml += `<b>From:</b> ${escapeHtmlServer(msg.getFrom())}<br>`; fullHtml += `<b>To:</b> ${escapeHtmlServer(msg.getTo())}<br>`; fullHtml += `<b>Date:</b> ${Utilities.formatDate(msg.getDate(), timeZone, 'yyyy/MM/dd HH:mm:ss')}<br>`; fullHtml += `</div>`; fullHtml += `<div class="body">${msg.getBody()}</div>`; if (idx < messages.length - 1) fullHtml += `<hr>`; const attachments = msg.getAttachments(); attachments.forEach(att => { const uniqueName = getUniqueName(att.getName()); blobs.push(att.copyBlob().setName(uniqueName)); }); }); fullHtml += `</body></html>`; const pdfName = getUniqueName(`${cleanSubject}.pdf`); const pdfBlob = Utilities.newBlob(fullHtml, 'text/html', `${cleanSubject}.html`) .getAs('application/pdf') .setName(pdfName); blobs.unshift(pdfBlob); const zipBlob = Utilities.zip(blobs, `${baseName}.zip`); const base64Data = Utilities.base64Encode(zipBlob.getBytes()); return { success: true, fileName: `${baseName}.zip`, base64: base64Data }; } catch (e) { console.error('exportEmailAsZip error:', e); return { success: false, error: e.message }; } } function escapeHtmlServer(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } /** * スレッドに対して正規の返信を送信する */ function replyToEmail(threadId, toAddress, _subjectText, bodyText, attachmentFiles) { try { const thread = GmailApp.getThreadById(threadId); if (!thread) return { success: false, error: 'スレッドが見つかりません' }; const options = { htmlBody: bodyText.replace(/\n/g, '<br>') }; if (attachmentFiles && attachmentFiles.length > 0) { options.attachments = attachmentFiles.map(file => { const decoded = Utilities.base64Decode(file.base64); return Utilities.newBlob(decoded, file.mimeType, file.name); }); } // 最新メッセージの送信者と toAddress を比較し、追加の宛先があれば cc に追加 const messages = thread.getMessages(); const latestMsg = messages[messages.length - 1]; const originalFrom = latestMsg.getFrom(); if (toAddress && toAddress.trim()) { const targetEmails = toAddress.split(',').map(s => s.trim()).filter(Boolean); const extraRecipients = targetEmails.filter(addr => !originalFrom.includes(addr)); if (extraRecipients.length > 0) { options.cc = extraRecipients.join(', '); } } // スレッドの文脈とヘッダー情報を維持して返信する thread.reply(bodyText, options); return { success: true }; } catch (e) { console.error('replyToEmail error:', e); return { success: false, error: e.message }; } } function getAttachment(messageId, attachmentIndex) { try { const msg = GmailApp.getMessageById(messageId); if (!msg) return { success: false, error: 'メッセージが見つかりません' }; const attachments = msg.getAttachments(); if (attachmentIndex < 0 || attachmentIndex >= attachments.length) { return { success: false, error: '添付ファイルが見つかりません' }; } const att = attachments[attachmentIndex]; return { success: true, name: att.getName(), mimeType: att.getContentType(), base64: Utilities.base64Encode(att.getBytes()) }; } catch (e) { console.error('getAttachment error:', e); return { success: false, error: e.message }; } } function permanentlyDeleteMessage(threadId) { try { Gmail.Users.Threads.remove('me', threadId); return { success: true }; } catch (e) { const msg = e.message || ''; if (msg.includes('not found') || msg.includes('404') || msg.includes('NotFound')) { return { success: true, note: 'Already deleted' }; } console.error('permanentlyDeleteMessage error:', e); return { success: false, error: msg }; } } </details> <details><summary>appsscript.json(設定)</summary> { "timeZone": "Asia/Tokyo", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Gmail", "version": "v1", "serviceId": "gmail" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "webapp": { "executeAs": "USER_DEPLOYING", "access": "MYSELF" } } </details> <details><summary>Index.html(画面・約 1,830 行)</summary> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <!-- PWA / 全画面アプリ化 --> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="Mクイック"> <meta name="application-name" content="Mクイック"> <meta name="theme-color" content="#ffffff" id="metaThemeColor"> <link rel="apple-touch-icon" href="data:image/svg+xml;utf8,<svg xmlns='w3.org' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%230284c7'/><path d='M110 165l146 110 146-110' fill='none' stroke='white' stroke-width='32' stroke-linecap='round' stroke-linejoin='round'/><rect x='90' y='140' width='332' height='232' rx='28' fill='none' stroke='white' stroke-width='32'/><polygon points='280,210 220,320 270,320 240,410 330,290 270,290' fill='%23facc15'/></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='w3.org' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%230284c7'/><path d='M110 165l146 110 146-110' fill='none' stroke='white' stroke-width='32' stroke-linecap='round' stroke-linejoin='round'/><rect x='90' y='140' width='332' height='232' rx='28' fill='none' stroke='white' stroke-width='32'/><polygon points='280,210 220,320 270,320 240,410 330,290 270,290' fill='%23facc15'/></svg>"> <title>Mクイック - 超高速Gmailビューア</title> <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0"> <meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Expires" content="Wed, 11 Jan 1984 05:00:00 GMT"> <style> :root { --text-main: #000000; --text-muted: #1e293b; --line-border: #cbd5e1; --bg-header: #e2e8f0; --bg-btn: #f1f5f9; --bg-selected: #bae6fd; --bg-body: #e2e8f0; --bg-container: #ffffff; --ui-height: 32px; --sb-track: #f1f5f9; --sb-thumb: #94a3b8; --sb-thumb-hover: #64748b; --row-height: 56px; --card-bg: #ffffff; --card-border: #cbd5e1; --card-radius: 8px; --card-gap: 8px; --card-hover-bg: #e0f2fe; --card-hover-border: #0284c7; --card-selected-bg: #7dd3fc; --card-selected-border: #0284c7; --table-area-bg: #e2e8f0; --text-accent: #0284c7; --text-accent-hover: #0369a1; } body.dark-mode { --text-main: #f8fafc; --text-muted: #94a3b8; --line-border: #1e293b; --bg-header: #0b111e; --bg-btn: #1e293b; --bg-selected: #112547; --bg-body: #050811; --bg-container: #0b111e; --sb-track: #0b111e; --sb-thumb: #1e293b; --sb-thumb-hover: #334155; --card-bg: #111d33; --card-border: rgba(255, 255, 255, 0.08); --card-radius: 8px; --card-gap: 8px; --card-hover-bg: #162642; --card-hover-border: #2563eb; --card-selected-bg: #112547; --card-selected-border: #38bdf8; --table-area-bg: #0b111e; --text-accent: #38bdf8; --text-accent-hover: #7dd3fc; } body.green-mode { --text-main: #000000; --text-muted: #064e3b; --line-border: #86efac; --bg-header: #dcfce7; --bg-btn: #bbf7d0; --bg-selected: #86efac; --bg-body: #d1fae5; --bg-container: #ecfdf5; --sb-track: #d1fae5; --sb-thumb: #34d399; --sb-thumb-hover: #10b981; --ui-height: 32px; --row-height: 56px; --card-bg: #ffffff; --card-border: #86efac; --card-radius: 8px; --card-gap: 8px; --card-hover-bg: #a7f3d0; --card-hover-border: #047857; --card-selected-bg: #6ee7b7; --card-selected-border: #047857; --table-area-bg: #d1fae5; --text-accent: #047857; --text-accent-hover: #064e3b; } body.green-mode select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%23047857'/></svg>"); background-color: #ecfdf5; } body.green-mode .top-settings-menu-item:hover { background: #bbf7d0; color: #064e3b; } body.green-mode button.count-btn.active { background: #047857; color: #ffffff; border-color: #047857; font-weight: 700; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-font-smoothing: antialiased; background-color: var(--bg-body); margin: 0; padding: 20px; color: var(--text-main); transition: background-color 0.2s, color 0.2s; } .container { max-width: 1300px; margin: 0 auto; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 8px; display: flex; flex-direction: column; height: 92vh; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.02); transition: background-color 0.2s, border-color 0.2s; position: relative; } .top-bar { padding: 10px 20px; border-bottom: 1px solid var(--line-border); display: flex; align-items: center; justify-content: space-between; background: var(--bg-container); transition: background-color 0.2s, border-color 0.2s; } .title-area { display: flex; align-items: center; gap: 10px; } h1, h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-main); white-space: nowrap; user-select: none; } .sync-indicator { display: none; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600; color: #0369a1; background: #e0f2fe; border: 1px solid #bae6fd; padding: 2px 8px; border-radius: 12px; white-space: nowrap; } body.dark-mode .sync-indicator { color: #38bdf8; background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.3); } .controls { display: flex; align-items: center; gap: 12px; } .btn-group { display: flex; align-items: center; gap: 4px; } .btn-group span { font-size: 12px; font-weight: 600; margin-right: 2px; white-space: nowrap; color: var(--text-muted); } button.count-btn, select.menu-select, .search-box input { height: var(--ui-height); box-sizing: border-box; border: 1px solid var(--line-border); border-radius: 6px; font-size: 12px; font-weight: 500; background: var(--bg-container); color: var(--text-main); padding: 0 10px; outline: none; transition: all 0.15s; } button.count-btn { cursor: pointer; background: var(--bg-btn); border-color: var(--line-border); } button.count-btn:hover { background: var(--line-border); } button.count-btn.active { background: #38bdf8; color: #0f172a; border-color: #38bdf8; font-weight: 700; } button:disabled, select:disabled, input:disabled { opacity: 0.5; cursor: not-allowed !important; } body:not(.dark-mode) select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%23475569'/></svg>"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 22px; appearance: none; } body.dark-mode select.menu-select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='w3.org' width='10' height='10' viewBox='0 0 10 10'><path d='M0 3l5 5 5-5z' fill='%2394a3b8'/></svg>"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 22px; appearance: none; } .search-box { position: relative; display: flex; align-items: center; } .search-box input { padding: 0 24px 0 8px; width: 160px; } .search-box button { position: absolute; right: 6px; background: none; border: none; font-size: 18px; font-weight: 500; color: var(--text-muted); cursor: pointer; display: none; padding: 0; } .table-wrapper { overflow-y: auto; overflow-x: hidden; flex-grow: 1; outline: none; position: relative; background: var(--table-area-bg); padding: 8px 16px; } #mainTable { width: 100%; border-collapse: separate; border-spacing: 0 var(--card-gap); table-layout: fixed; } #mainTable thead th { position: sticky; top: 0; z-index: 10; background: var(--table-area-bg); font-size: 12px; color: var(--text-muted); padding: 4px 14px; border: none; text-align: left; font-weight: 600; letter-spacing: 0.02em; } #mainTable tbody tr { height: var(--row-height); cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; } #mainTable td { padding: 0 14px; font-size: 13px; color: var(--text-main); overflow: hidden; font-weight: 400; vertical-align: middle; background-color: var(--card-bg); border-top: 1px solid var(--card-border); border-bottom: 1px solid var(--card-border); transition: background-color 0.15s ease, border-color 0.15s ease; } #mainTable td:first-child { border-left: 1px solid var(--card-border); border-top-left-radius: var(--card-radius); border-bottom-left-radius: var(--card-radius); } #mainTable td:last-child { border-right: 1px solid var(--card-border); border-top-right-radius: var(--card-radius); border-bottom-right-radius: var(--card-radius); } #mainTable tbody tr.processing { opacity: 0.4; pointer-events: none; } #mainTable tbody tr:hover td { background-color: var(--card-hover-bg) !important; border-color: var(--card-hover-border) !important; } #mainTable tr.selected td, #mainTable tbody tr.selected td { background-color: var(--card-selected-bg) !important; border-color: var(--card-selected-border) !important; font-weight: 600; color: var(--text-main) !important; } #mainTable tr.selected td:first-child, #mainTable tbody tr.selected td:first-child { border-left: 6px solid var(--card-selected-border) !important; padding-left: 10px !important; } #mainTable tr.selected td:last-child, #mainTable tbody tr.selected td:last-child { border-right: 2px solid var(--card-selected-border) !important; } #mainTable tr.selected td, #mainTable tbody tr.selected td { border-top: 2px solid var(--card-selected-border) !important; border-bottom: 2px solid var(--card-selected-border) !important; } #mainTable tr.selected .subject-text { color: var(--text-main) !important; font-weight: 600; } #mainTable tr.selected .col-from { color: var(--text-main) !important; font-weight: 700; } #mainTable tr.selected .clickable { color: var(--text-main) !important; } #mainTable tr.selected .clickable:hover { color: var(--text-accent-hover) !important; } tr.unread td { font-weight: 600; } tr.unread .subject-text { font-weight: 600; color: var(--text-accent) !important; } tr.selected.unread .subject-text { color: var(--text-accent) !important; font-weight: 700; } .clickable { cursor: pointer; } .clickable:hover { text-decoration: underline; color: var(--text-accent-hover) !important; } .col-date { width: 90px; text-align: center; color: var(--text-muted); font-size: 12px; } .col-from { width: 160px; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .col-subject { width: auto; position: relative; } .subject-wrapper { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; line-height: 1.35; max-height: 2.7em; word-break: break-word; } .subject-text { font-weight: 400; color: var(--text-main); } .label-container { display: inline-flex; align-items: center; gap: 4px; position: absolute; right: 14px; top: 50%; transform: translateY(-50%); background: var(--bg-container); padding: 2px 6px; border-radius: 4px; box-shadow: -4px 0 12px var(--bg-container); z-index: 5; opacity: 0; transition: opacity 0.1s ease; pointer-events: none; } #mainTable tbody tr:hover .label-container { opacity: 1; pointer-events: auto; } .label-badge { display: inline-flex; align-items: center; padding: 1px 6px; font-size: 10.5px; background: var(--bg-btn) !important; border: 1px solid var(--line-border); border-radius: 4px; color: var(--text-main) !important; font-weight: 600; white-space: nowrap; } .remove-badge-btn { margin-left: 4px; cursor: pointer; font-weight: 700; color: var(--text-muted); padding: 0 1px; } .remove-badge-btn:hover { color: #ef4444; } .col-action { width: 155px; text-align: center; border-right: none; padding-left: 2px !important; padding-right: 2px !important; } .action-container { display: flex; align-items: center; justify-content: center; gap: 4px; width: 100%; flex-wrap: nowrap !important; white-space: nowrap !important; } .icon-btn { background: rgba(148, 163, 184, 0.08) !important; border: 1px solid var(--card-border) !important; font-size: 13px; cursor: pointer; margin: 0; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s; width: 28px; height: 28px; box-sizing: border-box; flex-shrink: 0 !important; color: var(--text-main); } .icon-btn:hover { background: var(--bg-btn) !important; border-color: var(--text-muted) !important; transform: translateY(-1px); } .top-settings-menu { position: absolute; top: 48px; right: 20px; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.2); z-index: 1500; min-width: 180px; padding: 6px 0; display: none; flex-direction: column; } .top-settings-menu.show { display: flex; } .top-settings-menu-item { padding: 8px 16px; font-size: 13px; color: var(--text-main); cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 10px; } .top-settings-menu-item:hover { background: var(--bg-btn); color: #38bdf8; } .dropdown-divider { height: 1px; background: var(--line-border); margin: 4px 0; } .custom-dropdown-menu { position: fixed; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 8px; box-shadow: 0 10px 20px rgba(0,0,0,0.2); z-index: 1000; min-width: 150px; padding: 4px 0; max-height: 50vh; overflow-y: auto; } .dropdown-item { padding: 6px 12px; font-size: 12.5px; color: var(--text-main); cursor: pointer; font-weight: 500; } .dropdown-item:hover { background: var(--bg-btn); color: #38bdf8; } .date-main { font-weight: 500; display: block; font-size: 12px; color: var(--text-main); pointer-events: none; } .date-sub { font-size: 10.5px; color: var(--text-muted); font-weight: 500; display: block; margin-top: 1px; pointer-events: none; } .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; z-index: 2000; } .modal-content { background: #1e293b; color: #f8fafc; width: 85%; max-width: 840px; max-height: 82vh; border-radius: 12px; padding: 24px; overflow-y: auto; position: relative; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3); } .modal-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 2px solid #38bdf8; padding-bottom: 10px; margin-bottom: 16px; } .modal-title { font-size: 17px; font-weight: 600; margin: 0; color: #f8fafc; display: flex; align-items: center; gap: 6px; } .close-btn { padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; color: #f8fafc; border: 1px solid #475569; background: #334155; border-radius: 6px; transition: background 0.15s; } .close-btn:hover { background: #475569; } .email-modal-container { width: 90%; max-width: 950px; height: 85vh; display: flex; flex-direction: column; padding: 18px 24px; background: var(--bg-container); color: var(--text-main); border: 1px solid var(--line-border); } .email-modal-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 12px; border-bottom: 1px solid var(--line-border); } .email-modal-title { font-size: 16px; font-weight: 600; color: var(--text-main); margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; } .email-modal-body { flex: 1; overflow-y: auto; padding-top: 14px; display: flex; flex-direction: column; } .msg-card { border: 1px solid var(--line-border); border-radius: 8px; background: var(--bg-header); padding: 12px 16px; margin-bottom: 14px; transition: all 0.2s; } .msg-meta { font-size: 12px; color: var(--text-muted); line-height: 1.6; margin-bottom: 10px; border-bottom: 1px dashed var(--line-border); padding-bottom: 8px; transition: all 0.2s; overflow: hidden; } .msg-meta strong { color: var(--text-main); } .msg-attachments { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; } .attachment-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 4px; background: var(--bg-btn); border: 1px solid var(--line-border); font-size: 11px; color: var(--text-main); } .clickable-attachment { cursor: pointer; transition: filter 0.15s, color 0.15s; } .clickable-attachment:hover { filter: brightness(0.9); color: #0284c7 !important; border-color: #7dd3fc; } body.dark-mode .clickable-attachment:hover { filter: brightness(1.2); color: #38bdf8 !important; } .email-frame { width: 100%; border: none; background: #ffffff; border-radius: 4px; min-height: 180px; } .open-ext-btn { padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; color: #0284c7; background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; } details.recipients-box { margin: 3px 0; } details.recipients-box summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; user-select: none; outline: none; } details.recipients-box summary::-webkit-details-marker { display: none; } .recipients-summary-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); font-size: 12px; flex: 1; } .recipients-badge { font-size: 11px; color: #38bdf8; background: rgba(56, 189, 248, 0.12); border: 1px solid rgba(56, 189, 248, 0.3); padding: 1px 7px; border-radius: 4px; white-space: nowrap; } .recipients-full-list { margin-top: 6px; padding: 8px 10px; background: var(--bg-container); border: 1px solid var(--line-border); border-radius: 6px; max-height: 120px; overflow-y: auto; font-size: 11.5px; line-height: 1.5; word-break: break-all; } .m-section { margin-bottom: 24px; } .m-h2 { font-size: 14.5px; font-weight: 700; margin-top: 22px; margin-bottom: 10px; padding: 4px 8px; border-left: 4px solid #38bdf8; background: rgba(56, 189, 248, 0.08); color: #38bdf8; border-radius: 0 4px 4px 0; } .m-p { font-size: 13px; color: #cbd5e1; line-height: 1.6; margin: 8px 0; font-weight: 400; } .m-ul { font-size: 13px; color: #cbd5e1; line-height: 1.7; padding-left: 20px; margin: 6px 0 12px 0; font-weight: 400; } .m-ul li { margin-bottom: 6px; } .m-ul strong { font-weight: 600; color: #f8fafc; } .m-tag { display: inline-block; padding: 1px 6px; font-size: 11px; border-radius: 4px; background: #334155; border: 1px solid #475569; color: #38bdf8; font-weight: 600; margin: 0 2px; } .m-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; } .m-table th { background: #0f172a !important; color: #38bdf8 !important; text-align: left; padding: 10px 12px; border: 1px solid #334155 !important; font-weight: 600; } .m-table td { background: #0f172a !important; color: #f8fafc !important; border: 1px solid #334155 !important; padding: 10px 12px; vertical-align: middle; border-radius: 0 !important; font-size: 13px; line-height: 1.6; } .m-table tbody tr:nth-child(even) td { background: #172033 !important; } .m-table td strong { color: #38bdf8 !important; } .m-callout { background: rgba(56, 189, 248, 0.05); border: 1px solid rgba(56, 189, 248, 0.2); border-radius: 6px; padding: 10px 14px; margin: 12px 0; font-size: 12.5px; color: #e2e8f0; line-height: 1.5; } - { scrollbar-width: thin; scrollbar-color: var(--sb-thumb) var(--sb-track); } ::-webkit-scrollbar { width: 12px; height: 12px; } ::-webkit-scrollbar-track { background: var(--sb-track); border-radius: 6px; } ::-webkit-scrollbar-thumb { background: var(--sb-thumb); border-radius: 6px; border: 3px solid var(--sb-track); } ::-webkit-scrollbar-thumb:hover { background: var(--sb-thumb-hover); } .btn-success { background: #10b981 !important; color: white !important; border-color: #059669 !important; } .btn-success:hover { background: #059669 !important; } .btn-primary { background: #38bdf8 !important; color: #0f172a !important; border-color: #0284c7 !important; font-weight: bold; } .btn-primary:hover { filter: brightness(0.9); } #replyFormContainer { display: none; border-top: 1px solid var(--line-border); padding: 16px 0 0 0; margin-top: 8px; } .reply-to-input { flex: 1; height: 30px; padding: 0 8px; border: 1px solid var(--line-border); border-radius: 4px; font-size: 13px; background: var(--bg-container); color: var(--text-main); outline: none; transition: border-color 0.2s; font-family: inherit; } .reply-to-input:focus { border-color: #38bdf8; } .reply-textarea { width: 100%; box-sizing: border-box; height: 150px; padding: 12px; border: 1px solid var(--line-border); border-radius: 6px; resize: vertical; font-family: inherit; font-size: 13.5px; background: var(--bg-container); color: var(--text-main); outline: none; transition: border-color 0.2s; } .reply-textarea:focus { border-color: #38bdf8; } .form-label { width: 55px; font-size: 13px; font-weight: 600; color: var(--text-main); text-align: right; padding-right: 8px; display: inline-block; } /* ========================================== スマートフォン最適化レスポンシブスタイル ========================================== */ @media (max-width: 640px) { body { padding: 0 !important; margin: 0 !important; height: 100vh !important; height: 100dvh !important; overflow: hidden !important; } .container { max-width: 100% !important; width: 100% !important; height: 100vh !important; height: 100dvh !important; border: none !important; border-radius: 0 !important; margin: 0 !important; } .top-bar { padding: 8px 10px !important; flex-direction: column !important; gap: 8px !important; align-items: stretch !important; } .title-area { width: 100% !important; display: flex !important; justify-content: space-between !important; align-items: center !important; } .top-bar .controls { width: 100% !important; display: flex !important; align-items: center !important; gap: 6px !important; justify-content: space-between !important; } .top-bar .controls .menu-select { width: 86px !important; min-width: 86px !important; padding-left: 6px !important; padding-right: 18px !important; font-size: 11.5px !important; } .top-bar .controls .search-box { flex: 1 1 auto !important; min-width: 0 !important; } .top-bar .controls .search-box input { width: 100% !important; font-size: 11.5px !important; } .top-bar .controls .btn-group { flex: 0 0 auto !important; gap: 2px !important; } .top-bar .controls .btn-group span { display: none !important; } .top-bar .controls button.count-btn { padding: 0 6px !important; font-size: 11px !important; } /* スワイプアクション用アニメーション */ .swipe-container { position: relative; overflow: hidden; } .swipe-action-bg { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 0 20px; font-weight: 700; font-size: 14px; color: white; border-radius: 8px; z-index: 1; opacity: 0; transition: opacity 0.15s ease; } .swipe-action-archive { background: #10b981; justify-content: flex-start; } .swipe-action-delete { background: #ef4444; justify-content: flex-end; } #mainTable tbody tr:not(.virtual-spacer) { touch-action: pan-y; user-select: none; -webkit-user-select: none; transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.15s; will-change: transform; } /* テーブルのスマホカード化 */ .table-wrapper { padding: 4px 8px !important; } #mainTable { display: block !important; width: 100% !important; table-layout: auto !important; border-spacing: 0 !important; } #mainTable thead { display: none !important; } #mainTable tbody { display: block !important; width: 100% !important; } .virtual-spacer { display: block !important; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; } .virtual-spacer td { display: block !important; border: none !important; padding: 0 !important; background: transparent !important; } #mainTable tbody tr:not(.virtual-spacer) { display: block !important; height: 114px !important; box-sizing: border-box !important; padding: 8px 10px !important; margin-bottom: 8px !important; border-radius: 8px !important; border: 1px solid var(--card-border) !important; background-color: var(--card-bg) !important; position: relative !important; } #mainTable tbody tr:not(.virtual-spacer) td { display: block !important; border: none !important; padding: 0 !important; background: transparent !important; } #mainTable tbody tr:not(.virtual-spacer) td:first-child, #mainTable tbody tr:not(.virtual-spacer) td:last-child { border-radius: 0 !important; border: none !important; } .col-from { display: block !important; font-size: 13.5px !important; font-weight: 700 !important; color: var(--text-main) !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; margin-right: 80px !important; line-height: 18px !important; } .col-date { position: absolute !important; top: 8px !important; right: 10px !important; width: auto !important; font-size: 11px !important; color: var(--text-muted) !important; text-align: right !important; white-space: nowrap !important; line-height: 18px !important; } .date-main { font-size: 11px !important; } .date-sub { display: none !important; } .col-subject { display: block !important; margin-top: 4px !important; width: 100% !important; font-size: 12.5px !important; line-height: 1.35 !important; color: var(--text-main) !important; } .subject-wrapper { display: -webkit-box !important; -webkit-line-clamp: 2 !important; -webkit-box-orient: vertical !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: normal !important; max-height: 2.7em !important; } .col-action { position: absolute !important; bottom: 6px !important; right: 10px !important; left: 10px !important; width: auto !important; padding-top: 4px !important; border-top: 1px dashed var(--line-border) !important; } .action-container { display: flex !important; justify-content: flex-end !important; gap: 10px !important; } .action-container button.icon-btn { width: 30px !important; height: 26px !important; font-size: 14px !important; background: var(--bg-btn) !important; border: 1px solid var(--line-border) !important; border-radius: 4px !important; } /* 選択時ハイライト(スマホ) */ #mainTable tbody tr.selected:not(.virtual-spacer) { background-color: var(--card-selected-bg) !important; border-color: var(--card-selected-border) !important; } /* モーダルのスマホ最適化 */ .modal-content { width: 95% !important; max-width: 95% !important; max-height: 92vh !important; padding: 12px !important; border-radius: 8px !important; } .email-modal-header { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; } .email-modal-header > div { flex-wrap: wrap !important; justify-content: flex-end !important; } .email-modal-title { font-size: 14px !important; } } </style> </head> <body> <div class="container"> <header class="top-bar" role="banner"> <div class="title-area"> <h1 id="pageTitle">Gmail 一覧</h1> <span id="syncBadge" class="sync-indicator">🔄 同期中...</span> </div> <div class="controls"> <div class="btn-group"> <label for="viewSelect" style="display:flex;align-items:center;">種別:</label> <select id="viewSelect" class="menu-select" onchange="changeView(this.value)" aria-label="メール種別切り替え"> <option value="inbox" selected>受信</option> <option value="sent">送信</option> <option value="archive">アーカイブ</option> <option value="all">全て</option> <option value="trash">ゴミ箱</option> <option disabled id="labelDivider">----------</option> </select> </div> <div class="search-box"> <input type="text" id="keywordInput" placeholder="一覧から絞り込み..." aria-label="メール絞り込みキーワード"> <button id="clearBtn" onclick="clearSearch()" aria-label="検索クリア">×</button> </div> <div class="btn-group" role="group" aria-label="表示件数切り替え"> <button id="cntBtn50" onclick="changeCount(50, this)" class="count-btn active" aria-label="50件表示">50</button> <button id="cntBtn100" onclick="changeCount(100, this)" class="count-btn" aria-label="100件表示">100</button> <button id="cntBtnAll" onclick="changeCount('all', this)" class="count-btn" aria-label="全件表示">全</button> </div> <button id="mainSettingsBtn" onclick="toggleSettingsMenu(event)" class="icon-btn" title="設定とメニュー" aria-label="設定とメニュー">⚙️</button> </div> </header> <div id="topSettingsMenu" class="top-settings-menu" role="menu" aria-label="設定メニュー"> <div class="top-settings-menu-item" onclick="handleMenuRefresh()" role="menuitem"> <span class="top-settings-menu-icon">🔄</span> 最新情報を取得 </div> <div class="top-settings-menu-item" onclick="handleCycleTheme()" role="menuitem"> <span class="top-settings-menu-icon" id="themeMenuIcon">☀️</span> <span id="themeMenuText">ホワイトモード</span> </div> <div class="dropdown-divider"></div> <div class="top-settings-menu-item" onclick="handleMenuManual()" role="menuitem"> <span class="top-settings-menu-icon">❓</span> 使い方マニュアル </div> </div> <main class="table-wrapper" id="tableArea" tabindex="0" onscroll="onScrollThrottled()" role="main"> <table id="mainTable"> <thead> <tr> <th class="col-date">日付</th> <th class="col-from">送付元・宛先</th> <th class="col-subject">件名</th> <th class="col-action">操作</th> </tr> </thead> <tbody id="listBody"> </tbody> </table> </main> </div> <!-- メール閲覧モーダル --> <div id="emailViewModal" class="modal-overlay" onclick="closeEmailModal()"> <div class="modal-content email-modal-container" onclick="event.stopPropagation()"> <div class="email-modal-header"> <h3 class="email-modal-title" id="viewModalSubject">読み込み中...</h3> <!-- ボタン群: 返信、Gmail、印刷、削除、閉じる の順 --> <div style="display: flex; gap: 8px; align-items: center;"> <button id="replyBtn" class="open-ext-btn btn-success" style="display: none;" onclick="toggleReplyForm()">返信</button> <a id="viewModalExtLink" href="#" target="_blank" class="open-ext-btn">Gmail</a> <button id="printBtn" class="open-ext-btn" onclick="printCurrentEmail()">印刷</button> <button id="deleteEmailBtn" class="open-ext-btn" onclick="deleteCurrentEmail(event)" title="削除" style="color: #ef4444; background: #fee2e2; border-color: #fecaca; font-size: 13px; padding: 4px 10px;">🗑️</button> <button class="close-btn" onclick="closeEmailModal()">閉じる</button> </div> </div> <div class="email-modal-body" id="viewModalScrollArea"> <div id="viewModalContent"> <div style="text-align: center; padding: 40px; color: var(--text-muted);">本文を読み込んでいます...</div> </div> <!-- 返信フォーム --> <div id="replyFormContainer"> <div style="margin-bottom: 12px; font-weight: 600; font-size: 14px; color: #38bdf8;"> ◆ 返信の作成 </div> <div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;"> <div style="display: flex; align-items: center;"> <span class="form-label">宛先(To):</span> <input type="text" id="replyToInput" class="reply-to-input" placeholder="宛先メールアドレスを入力してください"> </div> <div style="display: flex; align-items: center;"> <span class="form-label">件名:</span> <input type="text" id="replySubjectInput" class="reply-to-input" placeholder="件名を入力してください"> </div> </div> <textarea id="replyBody" class="reply-textarea" placeholder="返信内容を入力してください..."></textarea> <div style="margin-top: 12px; display: flex; align-items: center; justify-content: space-between;"> <input type="file" id="replyAttachments" multiple style="font-size: 12px; color: var(--text-muted); cursor: pointer;"> <div style="display: flex; gap: 8px;"> <button onclick="toggleReplyForm()" class="close-btn">キャンセル</button> <button id="sendReplyBtn" onclick="sendReply()" class="open-ext-btn btn-primary">送信する</button> </div> </div> </div> </div> </div> </div> <!-- マニュアルモーダル --> <div id="manualModal" class="modal-overlay" onclick="closeManualModal()"> <div class="modal-content" onclick="event.stopPropagation()"> <div class="modal-header">
GAS × Antigravity で「会話するだけでGASが直る」 秀GASマネージャーを作った話 はじめに 先日、Google Apps Script(GAS)を clasp を介さずに Python の MCP サーバーから直接操作する記事を書きました。 - 前回の記事: claspでやるのが馬鹿らしくなってきた ── Python MCPでGASを直撃したら、修正が爆速になってコードの質まで上がった話 手元にファイルを落とさず、Google Apps Script API をメモリ上で直撃するようにしたことで、修正から反映までの待ち時間が消え、作業のリズムが大きく変わりました。 せっかくここまで快適になったのだから、先に公開している「秀エクセルマネージャー(shu-excel-manager)」と同じように、誰でも使える形で GitHub に公開しよう。そう思い立ちました。 ただ、公開するからには簡単な手直しで済ませたくありません。 「どうせ出すなら、動作の速さを一番念頭に置いて、とことんまで作り込んでから出そう」 そう決めて、Antigravity の上で親エージェントに陣頭指揮(番頭役)を執らせ、サブエージェント(Gemini Flash)を実装部隊として動かす体制を組み、丸一日かけて徹底的な改修と実機検証を行いました。 こうして仕上がった 「秀GASマネージャー(shu-gas-manager)」 を、本日 GitHub に公開しました。 - GitHub リポジトリ: shu1551/shu-gas-manager この記事では、Antigravity と会話するだけで GAS が直り、その場で動き、診断まで終わるようになった仕組みと、公開前に行った徹底検証の裏側を紹介します。 TL;DR - Antigravity や Claude などの AI と会話するだけで GAS の読み書き・診断・実行・デプロイが完結する 「秀GASマネージャー(shu-gas-manager)」 を MIT ライセンスで公開しました - 動作速度を最優先に設計: プロジェクト名解決を 680ms ➔ 0.35ms、本番稼働コードとの差分比較(diff-prod)を 5.6秒 ➔ 0.305ms(約6,200倍高速)、2回目以降の健康診断を 99ms まで短縮しました - 4つの深層アップグレードを搭載: 1. 関数スコープ限定のピンポイント置換(patch-func)と複数ファイル一括トランザクション(batch) 2. クラウド上の過去世代一覧(versions)・本番稼働版との Unified Diff(diff-prod)・世代ロールバック(restore-version) 3. ループ内の重い API 呼び出しや HTML の RPC リンク切れ・引数ズレを見抜く 10項目健康診断(diagnose) 4. デプロイ不要で任意の GAS 関数をクラウド実行し、Logger.log と戻り値をその場で回収する リモート関数実行(run --fast) & ローカル双方向同期(pull / push) - 単体テスト81件 + クラウド実機総当たりテスト48項目 をすべて通過し、実機で起きた 0.2 秒の反映ラグや Windows の改行コード問題まで潰し切ってからのリリースです 「会話するだけでGASが直る」とはどういうことか 従来の GAS 開発では、ちょっとした修正でも人間の手作業がいくつも挟まっていました。ブラウザでスクリプトエディタを開くか、ターミナルで clasp pull してファイルを直し、clasp push して、さらにブラウザで関数を選択して「実行」ボタンを押し、実行ログを確認する。 秀GASマネージャーを Antigravity に繋ぐと、これらの画面移動がすべて消えます。人間がやることは、チャット欄で AI に話しかけるだけです。 たとえば、「〇〇の関数で、エラーになったときの戻り値を直して、実際に動くか試しておいて」と頼むと、AI は裏で次の手順を数秒のうちに自律的に回します。 1. outline(関数マップ取得)で対象ファイルと行番号を 0.1 秒で特定 2. get_function でその関数だけをピンポイント抽出(ファイル全文は読まない) 3. patch_function でその関数の { ... } の内側だけを安全に書き換え (反映前に Node.js の vm モジュールで JavaScript 構文エラーを自動検査) 4. run_function でクラウド上の関数を直接叩き、戻り値と Logger.log を回収して確認 人間はブラウザを一度も開きません。AI が自分でコードを直し、自分でクラウド上の GAS を動かしてログを確かめ、「直して実行確認まで終わりました。戻り値は 42、ログも正常です」と返してくるのです。 徹底的に作り込んだ4つの武器 公開にあたって、「ただコードを読み書きできるだけ」のツールから一歩踏み込み、実務で GAS を保守するときに本当に欲しくなる4つの機能を組み込みました。 1. 関数スコープ限定編集と、複数ファイル一括トランザクション GAS のファイルが大きくなってくると、ファイル全体を対象にした文字列置換(patch)では、別の関数にある同じ名前の変数までうっかり書き換えてしまうリスクがあります。 そこで、指定した関数の { ... } の範囲内だけで置換を行う patch_function(CLI では patch-func)と、関数の追加(重複チェック付き)・削除を行う add_function / delete_function を作りました。 さらに、サーバー側の .gs と画面側の .html の両方を同時に直す場面のために、複数の変更操作をメモリ上で一括合成し、1回のバックアップ + 1回の Node.js 一括構文検査 + 1回の API 通信 でアトミックに反映する batch_patch(batch)を実装しました。途中で1ファイルでも構文エラーがあれば、クラウド側は1バイトも書き換わらずに安全に停止します。 2. GAS特有の地雷を0.1秒で抜く「10項目健康診断(diagnose)」 GAS には、構文エラーにはならないのに実行すると破綻する「特有の地雷」がいくつもあります。これを diagnose コマンド一発で丸ごと検査できるようにしました。 実際に自作の Web アプリ「Qクイック NEXT」に対して py gas.py diagnose "Qクイック NEXT" を走らせたときの実機出力がこちらです。 === GAS プロジェクト健康診断: [Qクイック NEXT] === 判定: 正常 (HEALTHY) ファイル数: 5 | 総関数数: 10 | 検出された問題: 0件 [構文エラー] (0件): なし (全ファイル構文チェック合格) [重複関数・グローバル衝突] (0件): なし (グローバル名前空間での関数名衝突はありません) [グローバル変数・クラス衝突 (const/let/class)] (0件): なし (グローバル変数の衝突はありません) [HTML RPC リンク切れ (google.script.run)] (0件): なし (全ての google.script.run 呼び出し先関数が存在します) [RPC 引数不一致警告] (0件): なし (RPC引数の不一致はありません) [HTML テンプレートリンク切れ (HtmlService)] (0件): なし (全てのテンプレート参照先 HTML が存在します) [パフォーマンス警告 (ループ内同期API)] (1件): - GQuick_Server 行 334: UrlFetchApp.fetch -> ループ内での同期GAS API呼び出しは6分実行制限超過やクォータ枯渇の原因になります [未参照・未使用の疑いがある関数] (1件): - [GQuick_Server:486] getOfficialChannelNames [検出されたエントリーポイント]: - doGet -> GQuick_Server:614 [マニフェスト設定 (appsscript.json)]: - Runtime: V8 - TimeZone: Etc/GMT-9 - WebApp: access=MYSELF, executeAs=USER_DEPLOYING GAS はすべての .gs ファイルが同じグローバル空間を共有するため、別ファイルに同じ名前の関数やトップレベルの const / let を書くと静かに衝突します。また、HTML 側の google.script.run からサーバー側のプライベート関数(末尾が _ の関数)を呼ぶと無言で失敗します。 こうした GAS ならではの落とし穴に加え、for や forEach ループの中で SpreadsheetApp や UrlFetchApp を呼んでいる箇所(6分制限超過の原因)や、どこからも呼ばれていない未使用関数まで、静的解析で一瞬で洗い出します。 !検診 3. デプロイ不要でその場で関数を試す「リモート関数実行(run --fast)」 コードを直したあと、「この関数にテストデータを渡して、ちゃんと動くか今すぐ確かめたい」と思うことはしょっちゅうあります。しかし Google 標準の scripts.run API は、GCP プロジェクトの紐付けなど設定のハードルが非常に高く、気軽には使えません。 そこで秀GASマネージャーでは、開発用の @HEAD エンドポイントと一時ランナー注入を組み合わせた独自のリモート実行エンジン(run) を作りました。 仕組みはこうです。実行したい関数名と引数を渡すと、ツールが一時的に小さな受付フック(__gm_exec_)を注入し、OAuth トークン付きで最新 @HEAD を呼び出します。GAS 側で対象関数を実行し、戻り値・実行時間(ms)・Logger.log・console.log・エラースタックトレースを JSON で回収した直後に、finally ブロックで元のコードへ綺麗に戻します。 さらに、何度も連続で試したい開発中のために --fast モード を用意しました。ランナーを常駐させたままキャッシュで判定することで、2回目以降はコードの書き換え(PUT 通信)を一切行わず、1回の GET 通信だけ で即座に関数を実行します。作業が終わったら clean-runner を叩けば、注入したフックは跡形もなく消去されます。 # クラウド上の関数 e2eAdd(15, 27) をその場で実行してログと戻り値を回収 $ py gas.py run "MCPテスト" e2eAdd "[15, 27]" --fast [SUCCESS] 関数実行: e2eAdd (所要時間: 4ms) 戻り値: 42 --- 実行ログ --- calc=42 clog=405 !即試す 4. 本番稼働コードとの 0.3ミリ秒比較(diff-prod)と双方向同期(pull / push) Web アプリを運用していると、「いま本番で動いているバージョンと、手直しした最新の HEAD コードで、どこが違うんだっけ?」と確認したくなります。 diff-prod コマンドを叩けば、現在デプロイされている本番バージョンのコードをクラウドから取得し、最新 HEAD との Unified Diff を一発で表示します。そのまま特定の過去バージョンへ巻き戻す restore-version も備えています。 また、普段はメモリ直結で使いながら、「節目のタイミングで手元の Git リポジトリにも .gs や .html として書き出しておきたい」という場面のために、ローカルフォルダとの双方向同期(pull / status / push)も実装しました。push 時は変更のあるファイルだけを一括で Node.js 構文検査にかけ、差分が0件なら API 通信を一切行わずに 0.67ミリ秒 で即終了します。 速さへの執念 ── ミリ秒単位の実測ビフォーアフター 今回の作り込みで私が一番厳しく注文をつけたのが 「とにかく速く動くこと」 でした。AI が連続でコマンドを叩くとき、1回あたり2秒かかるのと0.1秒で返るのとでは、体感速度がまるで違うからです。 そのために、以下の徹底的な高速化を施しました。 1. 軽量メタデータキャッシュ(.cache.json)とコンテンツキャッシュ(.content_cache.json)の分離永続化: プロジェクト一覧・スクリプトID・デプロイURL・構文検査済みファイルの SHA-256 ハッシュをディスクに保持し、別プロセスから CLI を単発実行しても Drive API 通信をスキップしてミリ秒で解決します。 2. Node.js 構文検査の1プロセス一括バッチ化(_validate_syntax_batch): 従来はファイルごとに Node.js プロセスを起動していたものを、1回の Node.js 起動で全ファイルをまとめて vm.Script 検査する方式に変えました。さらに一度合格したコードは SHA-256 ハッシュで記憶し、未変更ファイルの再検査を 0ms にしました。 実機(Google クラウド上の実プロジェクト)で計測した改修前後のベンチマーク結果がこちらです。 | 操作内容 | 改修前 / 初回通信 | 改修後(キャッシュ / 高速パス) | 高速化倍率 | |---|---|---|---| | プロジェクト一覧取得 (list) | 682.8 ms(Drive API通信) | 0.74 〜 5.70 ms | 約 120 〜 900倍 | | スクリプトID解決 (resolve_script_id) | 約 680 ms(毎回一覧取得) | 名前指定 0.35 ms / ID指定 0.05 ms | 約 1,900倍 | | 10項目健康診断 (diagnose) | 初回 2,271.7 ms(Node一括検査) | 2回目以降 99.18 ms(ハッシュ記憶) | 約 23倍(0.1秒未満) | | 本番稼働版との差分比較 (diff-prod) | 初回 5,667.3 ms(Version取得) | 2回目以降 0.305 ms(不変版キャッシュ) | 約 6,200倍(0.3ミリ秒) | | リモート関数実行 (run --fast) | 毎回注入・撤去 4,646.2 ms | 2回目以降 1,460.9 ms(GAS内実行 2〜4ms) | 約 3.2倍(PUT通信ゼロ) | | ローカル同期 (pull / status / push) | 手動で1ファイルずつ取得・更新 | pull: 2.19 ms / status: 16.21 ms / push(差分なし): 0.67 ms | 差分0件時は通信ゼロ即答 | Antigravity ならではの「番頭AI × サブエージェント」公開前48連撃テスト 今回の開発では、Antigravity の特徴であるサブエージェント機能をフル活用しました。 私がメインの AI に「あなたが陣頭指揮(番頭)を執って、フラッシュ君(Gemini Flash のサブエージェント)に実装させる体制で、徹底的に作り込んでくれ」と指示を出したのです。 最初は簡単な改善案を出してきた番頭 AI に対し、「もっと時間をかけてでも徹底的に改良してほしい」とハッパをかけたところ、そこから番頭 AI とフラッシュ君の猛烈な連携が始まりました。 1. 第1段階(実装): 番頭 AI が詳細な設計書を書き、フラッシュ君が gas_client.py(約3,500行)・gas.py・gas_mcp_server.py を一気に実装し、単体テストを78件まで増やしてパスさせる。 2. 第2段階(番頭による意地悪監査): 「本当にまずいところはないか徹底的に検証しろ」と私が指示すると、番頭 AI がコードを1行目から最終行まで精査し、通常のテストでは見つからない7つのエッジケースを洗い出しました。 - Windows 環境で pull した際、改行コード(CRLF と LF)の違いで未編集ファイルが「変更あり」に化ける問題 - GAS でよくある同名の index.gs と index.html が共存するプロジェクトで batch を実行した際の辞書キー衝突 - 不変バージョンのソースコードを軽量キャッシュ側に保存してしまい、繰り返すと起動が重くなる問題 3. 第3段階(実機への全48項目総当たりテスト): さらに「モックだけでなく実機で全コマンドを徹底的に叩け」と指示し、Google クラウド上のテスト用プロジェクトに対して、全31サブコマンド・全26 MCP ツールを片っ端から実機発火させる総当たりスクリプトを走らせました。 すると、モックの単体テスト(81件)では100%通っていたにもかかわらず、実機でしか起きない2つの現象 がその場で炙り出されました。 - 1つ目は、CLI(別プロセス)でファイルを追加した直後に、常駐している MCP サーバー側が自分のメモリキャッシュ(TTL 30秒)を優先してしまい、追加されたばかりのファイルを見落とす現象。これはディスクキャッシュの更新時刻(st_mtime)をミリ秒比較する3行の修正で解決しました。 - 2つ目は、run でランナー関数を API PUT した直後(0ミリ秒後)に @HEAD URL を叩くと、Google 側のサーバー反映に約 0.2 秒のタイムラグがあり、1回目だけ古い doGet が返ってくる現象。これも自動リトライ(0.4秒待機)を入れることで、初回から1発で通るようになりました。 最後にテスト用プロジェクトの全ファイルがテスト前と 1バイトの狂いもなく完全一致(原状回復) していることまで自動確認し、実機総当たり 48 PASS / 0 FAIL、単体テスト 81 PASS を達成してからの GitHub 公開となりました。 「AI にコードを書かせる」だけでなく、「親 AI に監査役を担わせ、実機テストで自分の道具の死角を炙り出させて直す」。このサイクルを回せたことが、今回の最大の収穫でした。 !総点検 1分で使える導入手順 秀GASマネージャーは、すでに clasp login 済みの環境なら 面倒な GCP 設定や OAuth 同意画面の作成を一切せずに 1 分で導入 できます。 # 1. リポジトリのクローンと依存パッケージのインストール git clone https://github.com/shu1551/shu-gas-manager.git cd shu-gas-manager pip install -r requirements.txt # 2. セットアップ診断(Google Apps Script API 設定画面の確認) py gas.py setup --open あとは、Antigravity や Claude Desktop、Cursor などの MCP 設定ファイルに以下を登録するだけです。 { "mcpServers": { "gas-manager": { "command": "python", "args": ["C:/path/to/shu-gas-manager/gas_mcp_server.py"] } } } リポジトリには、AI が最短手数(outline ➔ get_function ➔ patch_function ➔ diagnose)で賢く動くためのスキル定義書(SKILL.md)も同梱しています。 おわりに 最初は「毎回 clasp push するのが面倒だ」という素朴な疑問から始まった小さな道具でしたが、速度をミリ秒単位まで突き詰め、10項目の健康診断やリモート関数実行、世代比較まで組み込んだことで、GAS 開発の景色がまるで変わりました。 Excel VBA を直撃する「秀エクセルマネージャー」と、Google Apps Script を直撃する今回の「秀GASマネージャー」。場所はデスクトップとクラウドで正反対ですが、根底にある考え方はまったく同じです。 AI に人間と同じ遠回りな画面操作をさせるのではなく、AI が一番速く・安全に力を発揮できる「直通の道具」を作って渡してあげること。 GAS を日常的に触っている方や、Antigravity・Claude で GAS 開発をもっと快適にしたい方は、ぜひ一度試してみてください。