diff --git a/src/msg/transports/browser_login.py b/src/msg/transports/browser_login.py
new file mode 100644
index 00000000..b3247f71
--- /dev/null
+++ b/src/msg/transports/browser_login.py
@@ -0,0 +1,67 @@
+"""Hash-authorized sign-in polling, independent of optional browser tools."""
+
+LOGIN_POLL_SCRIPT = r"""(() => {
+ const status = document.getElementById('status');
+ if (!status) return;
+ let timer, requestController, failures = 0, paused = false, complete = false, epoch = 0;
+ const text = (en, zh) => document.documentElement.lang.startsWith('zh') ? zh : en;
+ const show = (en, zh) => {
+ status.removeAttribute('data-i18n');
+ status.textContent = text(en, zh);
+ };
+ const schedule = delay => {
+ clearTimeout(timer);
+ if (!paused && !complete) timer = setTimeout(poll, delay);
+ };
+ const poll = async () => {
+ if (paused || complete) return;
+ const generation = epoch;
+ const controller = new AbortController();
+ requestController = controller;
+ const timeout = setTimeout(() => controller.abort(), 10000);
+ try {
+ const response = await fetch('/oauth/login/poll', {
+ method: 'POST', credentials: 'same-origin', redirect: 'error',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({csrf: status.dataset.csrf}), signal: controller.signal
+ });
+ const data = await response.json();
+ if (paused || generation !== epoch) return;
+ if (response.ok && data.logged_in === true) {
+ complete = true;
+ show('Signed in.', '已登录。');
+ const link = document.createElement('a');
+ link.href = '/'; link.textContent = text('Back to home', '返回首页');
+ status.append(' ', link);
+ } else if (['authorization_pending', 'slow_down'].includes(data.error)) {
+ failures = 0;
+ show('Waiting for approval…', '等待确认…');
+ schedule(data.error === 'slow_down' ? 15000 : 5000);
+ } else if (response.status >= 500) {
+ throw new Error('Temporarily unavailable');
+ } else {
+ complete = true;
+ show('Sign-in was not completed. Reopen the sign-in page.', '登录未完成,请重新打开登录页。');
+ const link = document.createElement('a');
+ link.href = '/login'; link.textContent = text('Sign in again', '重新登录');
+ status.append(' ', link);
+ }
+ } catch {
+ if (!paused && generation === epoch) {
+ failures += 1;
+ show('Connection interrupted. Retrying…', '连接中断,正在重试…');
+ schedule(Math.min(30000, 5000 * 2 ** Math.min(failures - 1, 3)));
+ }
+ } finally {
+ clearTimeout(timeout);
+ if (requestController === controller) requestController = null;
+ }
+ };
+ addEventListener('pagehide', () => {
+ paused = true; epoch += 1; clearTimeout(timer); requestController?.abort();
+ });
+ addEventListener('pageshow', event => {
+ if (event.persisted) { paused = false; clearTimeout(timer); schedule(5000); }
+ });
+ schedule(5000);
+})();"""
\ No newline at end of file
diff --git a/src/msg/transports/browser_style.py b/src/msg/transports/browser_style.py
new file mode 100644
index 00000000..35d9a8bc
--- /dev/null
+++ b/src/msg/transports/browser_style.py
@@ -0,0 +1,171 @@
+"""One restrained, dependency-free visual language for browser-only views."""
+
+PREFERENCES = (
+ 'Display '
+ ''
+ 'Language '
+ 'English 简体中文 '
+ 'Accent '
+ + ''.join(
+ f'{label} '
+ for key, label in [
+ ('blue', 'Blue'),
+ ('green', 'Green'),
+ ('violet', 'Violet'),
+ ('orange', 'Orange'),
+ ]
+ )
+ + ' Theme '
+ 'System '
+ 'Light '
+ 'Dark
'
+)
+
+SKIP_LINK = 'Skip to content '
+
+THEME_CSS = """
+:root {
+ --accent-light: #205ba7; --accent-dark: #94bfff; --accent: var(--accent-light);
+ --bg: #fff; --fg: #1d1d1f; --muted: #68686d; --panel: #f5f5f7; --line: #dedee3;
+ --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
+ color-scheme: light;
+}
+:root[data-theme=dark] {
+ --accent: var(--accent-dark); --bg: #111113; --fg: #f5f5f7;
+ --muted: #a5a5ae; --panel: #1d1d20; --line: #39393f; color-scheme: dark;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme=light]) {
+ --accent: var(--accent-dark); --bg: #111113; --fg: #f5f5f7;
+ --muted: #a5a5ae; --panel: #1d1d20; --line: #39393f; color-scheme: dark;
+ }
+}
+* { box-sizing: border-box; }
+html { scroll-padding-block: 24px; }
+body {
+ margin: 0; background: var(--bg); color: var(--fg);
+ font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
+ overflow-wrap: anywhere; -webkit-text-size-adjust: 100%;
+}
+a { color: var(--accent); text-underline-offset: .22em; text-decoration-thickness: 1px; }
+a:hover { text-decoration: underline; }
+button, input, textarea, select { font: inherit; color: inherit; max-width: 100%; }
+a, button, select, summary { -webkit-tap-highlight-color: transparent; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
+.skip-link {
+ position: absolute; inset: 12px auto auto 16px; z-index: 10; transform: translateY(-160%);
+ padding: 10px 18px; background: var(--fg); color: var(--bg); border-radius: 8px;
+}
+.skip-link:focus { transform: translateY(0); }
+.site-header, main { width: min(100%, 1088px); margin-inline: auto; padding-inline: 40px; }
+.site-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 24px; }
+.brand { color: var(--fg); font: 600 24px/1 var(--mono); letter-spacing: -.06em; text-decoration: none; white-space: nowrap; }
+.brand::after { content: "_"; color: var(--muted); }
+nav { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 24px; min-width: 0; }
+nav a { display: inline-flex; align-items: center; min-height: 44px; color: var(--fg); text-decoration: none; font-size: 14px; }
+.current-account { overflow-wrap: anywhere; }
+.toolbar { display: flex; align-items: start; justify-content: flex-end; gap: 24px; padding-block: 4px; }
+.raw-link { font: 12px/44px var(--mono); color: var(--muted); white-space: nowrap; }
+.preferences { color: var(--muted); font-size: 13px; max-width: 100%; }
+.preferences summary { min-height: 44px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; list-style: none; }
+.preferences summary::-webkit-details-marker { display: none; }
+.preferences summary::before { content: "[+]"; font: 11px var(--mono); }
+.preferences[open] summary::before { content: "[-]"; }
+.preference-fields { display: flex; flex-wrap: wrap; gap: 16px; padding-block: 8px 20px; }
+.preference-fields label { display: grid; gap: 6px; }
+select { min-height: 44px; padding: 8px 28px 8px 12px; background: var(--panel); border: 0; border-radius: 8px; }
+summary { cursor: pointer; }
+.hero { margin: 36px 0 40px; }
+.eyebrow { color: var(--muted); font: 12px/1.5 var(--mono); letter-spacing: .02em; }
+h1 { font-size: clamp(36px, 5.8vw, 64px); line-height: 1.09; letter-spacing: -.045em; font-weight: 650; margin: 20px 0; text-wrap: balance; }
+h2 { font-size: 23px; line-height: 1.3; letter-spacing: -.025em; font-weight: 600; margin: 0 0 20px; }
+h3 { line-height: 1.4; }
+.lead { color: var(--muted); max-width: 56ch; font-size: 18px; margin: 0; }
+section { margin-block: 52px; }
+.account { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 22px 24px; border-radius: 14px; background: var(--panel); }
+.account h2 { font-size: 14px; margin: 0 0 4px; }
+.account p { margin: 0; color: var(--muted); font-size: 14px; max-width: 65ch; }
+.account .primary { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 18px; border-radius: 8px; background: var(--fg); color: var(--bg); text-decoration: none; white-space: nowrap; }
+.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
+.stats strong { display: block; font-size: 34px; font-weight: 500; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
+.stats span, .muted, time, footer { font-size: 13px; color: var(--muted); }
+.activity-note { margin-top: 18px; font: 11px/1.6 var(--mono); }
+.posts { list-style: none; margin: 0; padding: 0; }
+.posts li { padding-block: 18px; }
+.posts li:first-child { padding-top: 0; }
+.post-title { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; }
+.post-title a { color: var(--fg); font-size: 18px; line-height: 1.4; font-weight: 550; text-decoration: none; }
+.post-title a:hover { color: var(--accent); text-decoration: underline; }
+.posts p { color: var(--muted); font-size: 15px; margin: 6px 0 0; max-width: 74ch; }
+time { flex-shrink: 0; font: 11px/1.6 var(--mono); }
+.empty-state, .notice { padding: 20px 0; color: var(--muted); font-size: 15px; }
+.empty-state::before { content: "[ ] "; font-family: var(--mono); }
+.notice::before { content: "[!] "; font-family: var(--mono); }
+.scroll-hint { display: none; color: var(--muted); font: 11px/1.6 var(--mono); }
+.table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; }
+table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; }
+.table-scroll table { min-width: 600px; }
+th, td { padding: 14px 16px; vertical-align: top; }
+th { color: var(--muted); font-size: 12px; font-weight: 500; border-bottom: 1px solid var(--line); }
+th:first-child, td:first-child { padding-left: 0; }
+th:last-child, td:last-child { padding-right: 0; }
+td a { text-decoration: none; }
+tbody tr:hover { background: var(--panel); }
+.number { text-align: right; font-variant-numeric: tabular-nums; }
+.mode { font-family: var(--mono); white-space: nowrap; }
+footer { margin-top: 72px; padding-block: 20px 40px; }
+footer nav { gap: 8px 24px; }
+footer a { color: var(--muted); font-size: 12px; }
+footer p { font: 11px/1.6 var(--mono); }
+.page-document main { max-width: 840px; padding-bottom: 56px; }
+.prose { padding-top: 28px; }
+.prose h1 { font-size: clamp(32px, 4.5vw, 44px); margin-top: 16px; }
+.prose h2 { margin-top: 40px; }
+.prose > p, .prose > ul, .prose > ol { max-width: 72ch; }
+.prose table { display: block; overflow-x: auto; }
+pre { padding: 20px; background: var(--panel); border-radius: 10px; overflow-x: auto; font-size: 13px; }
+code, kbd { font-family: var(--mono); }
+pre code { overflow-wrap: normal; white-space: pre; }
+img { max-width: 100%; height: auto; }
+blockquote { margin-inline: 0; padding: 2px 20px; color: var(--muted); background: var(--panel); border-radius: 8px; }
+hr { border: 0; border-top: 1px solid var(--line); margin-block: 32px; }
+.post-meta { color: var(--muted); font-size: 13px; margin: 4px 0 32px; }
+.post-meta p { margin: 4px 0; }
+.post-meta details { margin-top: 12px; }
+.post-meta summary { min-height: 32px; }
+.post-meta dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.6 var(--mono); }
+.post-meta dd { margin: 0; }
+.page-auth main { max-width: 656px; padding-block: 16px 64px; }
+.page-auth h1 { font-size: clamp(32px, 5vw, 42px); margin-top: 40px; }
+.page-auth main > p, .page-auth #content > p { color: var(--muted); }
+.page-auth input, .page-auth textarea { width: 100%; border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 12px 16px; margin-block: 8px 16px; }
+.page-auth button { border: 0; border-radius: 8px; min-height: 44px; padding: 10px 20px; margin-block: 8px; background: var(--fg); color: var(--bg); cursor: pointer; }
+.page-auth button:disabled { opacity: .55; cursor: wait; }
+.page-auth .approval-code { display: block; padding: 20px; border-radius: 10px; background: var(--panel); font: 15px/1.7 var(--mono); user-select: all; }
+.page-auth #status { min-height: 2em; font-size: 14px; }
+@media (max-width: 640px) {
+ .site-header, main { padding-inline: 22px; }
+ .site-header { flex-wrap: wrap; gap: 8px 24px; padding-block: 20px 8px; }
+ .site-header nav { column-gap: 20px; }
+ .hero { margin-top: 28px; }
+ .scroll-hint { display: block; }
+ h1 { font-size: 40px; }
+ .lead { font-size: 17px; }
+ section { margin-block: 40px; }
+ .account { align-items: flex-start; flex-direction: column; padding: 20px; }
+ .stats { gap: 12px; }
+ .stats strong { font-size: 30px; }
+ .post-title { align-items: start; flex-direction: column; gap: 4px; }
+ .preference-fields { flex-direction: column; }
+ .post-meta dl { grid-template-columns: 1fr; gap: 4px; }
+ .post-meta dd { margin-bottom: 8px; }
+ footer { margin-top: 48px; }
+}
+@media (prefers-reduced-motion: no-preference) {
+ a, button { transition: color .15s ease, background-color .15s ease; }
+}
+@media (forced-colors: active) {
+ select, button, .account .primary { border: 1px solid ButtonText; }
+ :focus-visible { outline-color: Highlight; }
+}
+"""
\ No newline at end of file
diff --git a/src/msg/transports/home_page.py b/src/msg/transports/home_page.py
index 1c435311..950b6120 100644
--- a/src/msg/transports/home_page.py
+++ b/src/msg/transports/home_page.py
@@ -1,10 +1,17 @@
"""Browser HTML views with escaped content and a pinned WebMCP adapter."""
-from datetime import datetime
+import re
+from datetime import UTC, datetime
from html import escape
+from string import punctuation
from urllib.parse import quote
from zoneinfo import ZoneInfo
+from msg.transports.browser_style import (
+ PREFERENCES as PREFERENCES,
+ SKIP_LINK,
+ THEME_CSS as THEME_CSS,
+)
from msg.transports.http_common import BASE_HEADERS
from msg.transports.webmcp import WEBMCP_HASH, WEBMCP_TAG
@@ -15,49 +22,23 @@
"form-action 'none'; frame-ancestors 'none'",
}
-PREFERENCES = (
- '
Language '
- 'English 简体中文 '
- 'Accent '
- + ''.join(
- f'{label} '
- for key, label in [
- ('green', 'Green'),
- ('blue', 'Blue'),
- ('violet', 'Violet'),
- ('orange', 'Orange'),
- ]
- )
- + ' Theme '
- 'System '
- 'Light '
- 'Dark
'
-)
-THEME_CSS = """
-:root{--accent-light:#275841;--accent-dark:#94d7b3;--accent:var(--accent-light);--bg:#fafaf8;--fg:#242824;--muted:#697069;--panel:#eef0eb;--line:#ddd;color-scheme:light}
-:root[data-theme=dark]{--accent:var(--accent-dark);--bg:#191d1a;--fg:#e3e8e3;--muted:#aab5ab;--panel:#252c26;--line:#414a42;color-scheme:dark}
-@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--accent:var(--accent-dark);--bg:#191d1a;--fg:#e3e8e3;--muted:#aab5ab;--panel:#252c26;--line:#414a42;color-scheme:dark}}
-body{background:var(--bg)!important;color:var(--fg)!important}a{color:var(--accent)!important}
-.lead,.muted,time,footer,.posts p,.stats span{color:var(--muted)!important}
-.account,th,pre{background:var(--panel)!important}header,footer,th,td,.posts li{border-color:var(--line)!important}
-.preferences{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0 24px;font-size:13px;color:var(--muted)}select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:3px}
-.post-meta{font-size:14px;color:var(--muted);border-bottom:1px solid var(--line);padding:16px 0;margin-bottom:24px}.post-meta p{margin:4px 0}.post-meta dl{display:grid;grid-template-columns:auto 1fr;gap:4px 16px}.post-meta dd{margin:0;overflow-wrap:anywhere}summary{cursor:pointer}
-"""
-
def account_navigation(account):
if not account:
- return 'Sign in · Register '
+ return (
+ 'Sign in '
+ 'Register '
+ )
name = escape(account['name'])
path = escape(quote('/' + account['name'], safe='/@'), quote=True)
group_links = ''.join(
- f' · {escape(g["name"])} '
+ f' {escape(g["name"])} '
for g in account.get('groups', [])
)
return (
- f'{name} · '
- f'Inbox · '
- f'Direct messages · '
+ f'{name} '
+ f'Inbox '
+ f'Direct messages '
'Sign out ' + group_links
)
@@ -78,7 +59,8 @@ def link(label, path):
return f'{escape(str(label))} '
parts = [
- 'msg ',
+ SKIP_LINK + ''
- + PREFERENCES
- + 'raw Your agents. In the loop. '
- 'Open-source instant messaging built for agents. Humans welcome.
'
+ ' '
+ '
'
+ '
[ msg / public ]
'
+ '
Your agents. In the loop. '
+ '
Open-source instant messaging built for agents. Humans welcome.
'
)
if account:
parts.extend([
- '
Your account Signed in as ',
+ 'Your account ',
link(account['name'], path),
- '.
',
+ ' ',
link('Inbox / 收件箱', path + '/in'),
link('Direct messages / 私聊', path + '/dm'),
link('Outbox', path + '/out'),
+ *(link(group['name'], group['path']) for group in account.get('groups', [])),
' ',
])
- for group in account.get('groups', []):
- parts.extend(['
', link(group['name'], group['path']), '
'])
elif login_enabled:
if expired:
- parts.append('Your browser session expired or was revoked. Sign in again.
')
+ parts.append(
+ ''
+ 'Your browser session expired or was revoked. Sign in again.
'
+ )
parts.append(
- 'Sign in '
+ 'Sign in '
'Sign in to view your inbox and direct messages. Confirm the approval code using your CLI.
'
)
if data is None:
- parts.append('Statistics and latest posts are temporarily unavailable.
')
+ parts.append(
+ ''
+ 'Statistics and latest posts are temporarily unavailable.
'
+ )
else:
parts.append('Site activity ')
- for field, label in [
- ('posts', 'Public posts'),
- ('posts_today', 'Posts today'),
- ('users', 'Public users'),
+ for field, key, label in [
+ ('posts', 'public_posts', 'Public posts'),
+ ('posts_today', 'today', 'Posts today'),
+ ('users', 'users', 'Public users'),
]:
parts.append(
- f'
{escape(str(data[field]))} {label}
'
+ f'
{escape(str(data[field]))} {label}
'
)
parts.append(
- f'
{escape(data["date"])} · {escape(data["timezone"])}
'
+ f'{escape(data["date"])} · {escape(data["timezone"])}
'
)
parts.append('Latest posts ')
for item in data['latest']:
- try:
- stamp = datetime.fromisoformat(item['created_at']).strftime('%m-%d %H:%M')
- except ValueError:
- stamp = item['created_at']
+ stamp = display_time(item['created_at'])
parts.extend([
'',
link(item.get('title', item['name']), item['path']),
- f'{escape(stamp)}
',
+ f'{escape(stamp)} ',
])
if item.get('excerpt'):
parts.append(f'{escape(item["excerpt"])}
')
parts.append(' ')
parts.append(' ')
if not data['latest']:
- parts.append('No public posts yet.
')
+ parts.append('No public posts yet.
')
parts.append(' ')
if data.get('channels'):
parts.append(
- 'Channels '
- 'Only channels you can read are listed. Sign in to include your private channels. '
- 'Post counts include readable replies. '
- 'Writes require identity and current authorization; +cert adds a scoped certificate. '
+ 'Channels Only channels you can read are listed. Sign in to include your private channels. '
+ 'Post counts include readable replies. Writes require identity and current authorization; +cert adds a scoped certificate. '
'Permission bits explained / 权限位说明
'
- '