{title}
', + ]) + if item.get('excerpt'): + parts.append(f'{escape(item["excerpt"])}
') + parts.extend([ + 'diff --git a/.github/workflows/home-first-read.yml b/.github/workflows/home-first-read.yml
new file mode 100644
index 00000000..40258d6d
--- /dev/null
+++ b/.github/workflows/home-first-read.yml
@@ -0,0 +1,79 @@
+name: Browser first read
+on:
+ pull_request:
+ paths:
+ - 'src/msg/transports/**'
+ - 'src/msg/plugins/discovery*'
+ - 'tests/test_home*'
+ - 'tests/test_browser*'
+ - 'tests/test_thread*'
+ - 'scripts/check_home_first_read_browser.py'
+ - '.github/workflows/home-first-read.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'src/msg/transports/**'
+ - 'src/msg/plugins/discovery*'
+ - 'tests/test_home*'
+ - 'tests/test_browser*'
+ - 'tests/test_thread*'
+ - 'scripts/check_home_first_read_browser.py'
+ - '.github/workflows/home-first-read.yml'
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: browser-first-read-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: msgtest
+ POSTGRES_PASSWORD: msgtest
+ POSTGRES_DB: postgres
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd "pg_isready -U msgtest -d postgres"
+ --health-interval 10s --health-timeout 5s --health-retries 5
+ env:
+ MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.15'
+ allow-prereleases: true
+ - name: Install test tools
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq age libpcsclite-dev swig
+ python -m pip install uv==0.12.20
+ uv sync --locked --extra dev --python "$(command -v python)"
+ uv run python -m playwright install --with-deps chromium
+ - name: Verify first reads, authority and existing browser routes
+ run: |
+ mkdir -p artifacts
+ uv run pytest tests/test_home_first_read.py tests/test_home_cache.py tests/test_browser_refinement.py tests/test_browser_html.py tests/test_browser_languages.py tests/test_thread_panel.py tests/test_thread_panel_http.py tests/test_board_presentation.py --junitxml=artifacts/browser-first-read-tests.xml
+ - name: Verify real navigation and responsive rendering under CSP
+ env:
+ PYTHONPATH: src
+ run: uv run python scripts/check_home_first_read_browser.py
+ - name: Check changed source and tests
+ run: |
+ uv run ruff check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py
+ uv run ruff format --check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py
+ uv run python -W error -m compileall -q src
+ git diff --check
+ - uses: actions/upload-artifact@v7.0.1
+ if: always()
+ with:
+ name: browser-first-read-evidence
+ path: artifacts/
+ retention-days: 7
diff --git a/scripts/check_home_first_read_browser.py b/scripts/check_home_first_read_browser.py
new file mode 100644
index 00000000..c7fa34c5
--- /dev/null
+++ b/scripts/check_home_first_read_browser.py
@@ -0,0 +1,227 @@
+"""Verify first-view readability and navigation with actual HTML/CSP fixtures.
+
+The HTTP integration tests own cold-cache and authorization behavior. This
+isolated browser server uses sample data, not live identities or counters.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from playwright.sync_api import expect, sync_playwright
+
+from msg.plugins.public_board import default
+from msg.transports.home_page import HOME_BROWSER_HEADERS, document_html, home_html
+
+OUTPUT = Path('artifacts/home-first-read')
+POSTS = [
+ {
+ 'name': 'first-read',
+ 'path': '/main/first-read',
+ 'title': '让第一次打开,就能读到内容',
+ 'excerpt': '页面不必等待全站统计。先读帖子,再继续讨论;每次读取仍检查当前权限。',
+ 'created_at': '2026-10-09T14:00:00Z',
+ 'view_count': 12,
+ },
+ {
+ 'name': 'small-changes',
+ 'path': '/main/small-changes',
+ 'title': '留白,让讨论更清楚',
+ 'excerpt': '减少边框,保留内容。把标题、摘要和回复信息放到各自清楚的位置。',
+ 'created_at': '2026-10-09T13:00:00Z',
+ 'view_count': 7,
+ },
+ {
+ 'name': 'long-title',
+ 'path': '/main/long-title',
+ 'title': '一条比较长的中英文标题:Reliable first reads, without refreshing the whole page',
+ 'excerpt': '这是一组独立的浏览器测试数据,不是线上用户、帖子或统计。',
+ 'created_at': '2026-10-09T12:00:00Z',
+ 'view_count': 3,
+ },
+]
+DATA = {
+ 'posts': 22,
+ 'posts_today': 3,
+ 'users': 8,
+ 'latest': [],
+ 'date': '2026-10-09',
+ 'timezone': 'Asia/Taipei',
+ 'channels': [
+ {
+ 'name': 'main',
+ 'path': '/main',
+ 'about': '公开讨论 / Public discussions',
+ 'posts': 22,
+ 'mode': '0755',
+ 'posting': 'identity',
+ },
+ ],
+}
+POST_BODY = (
+ '---\nid: browser-fixture\n---\n\n# 让第一次打开,就能读到内容\n\n'
+ '正文在第一次访问时可见。没有刷新,没有隐藏的加载占位。\n\n'
+ '## 阅读优先\n\n帖子列表与全站统计独立读取。统计暂时不可用时,帖子仍可阅读。\n\n'
+ '## 小范围的修改\n\n保留现有登录、主题和权限。\n\n'
+ '```python\nprint("read on the first visit")\n```\n'
+)
+BOARD = default()
+
+
+def html(path):
+ if path in {'/', '/cold'}:
+ return home_html(
+ DATA if path == '/' else None,
+ post_index={'items': POSTS},
+ login_enabled=True,
+ public_board=BOARD,
+ )
+ if path == '/topics':
+ return document_html(
+ '# Topics\n\n## [让第一次打开,就能读到内容](/main/first-read)\n\n'
+ '公开讨论。\n\n## [留白,让讨论更清楚](/main/small-changes)\n\n'
+ '减少边框,保留内容。',
+ resource={'type': 'topic', '_post_index_next': None},
+ raw_path=path,
+ )
+ return document_html(
+ POST_BODY,
+ raw_path=path,
+ resource={
+ 'type': 'post',
+ 'id': 'r_browser_fixture',
+ 'revision': 'rev_fixture',
+ 'created_at': '2026-10-09T14:00:00Z',
+ 'view_count': 12,
+ 'links': {'a': {'path': '/@fixture'}, 't': {'path': '/main'}},
+ },
+ )
+
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ def do_GET(self):
+ path = urlsplit(self.path).path
+ if path == '/favicon.png':
+ self.send_response(204)
+ self.end_headers()
+ return
+ image = path == '/_public-board/art.svg'
+ payload = BOARD['svg'].encode() if image else html(path)
+ self.send_response(200)
+ self.send_header('Content-Type', 'image/svg+xml' if image else 'text/html; charset=utf-8')
+ for key, value in HOME_BROWSER_HEADERS.items():
+ self.send_header(key, value)
+ self.send_header('Content-Length', str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+
+def verify(page):
+ assert not page.evaluate('window.cspErrors'), page.evaluate('window.cspErrors')
+ assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ assert page.locator('#content').is_visible()
+
+
+def main():
+ OUTPUT.mkdir(parents=True, exist_ok=True)
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ origin = f'http://127.0.0.1:{server.server_port}'
+ results = []
+ try:
+ with sync_playwright() as playwright:
+ options = (
+ {'executable_path': os.environ['MSG_BROWSER_EXECUTABLE']}
+ if os.environ.get('MSG_BROWSER_EXECUTABLE')
+ else {}
+ )
+ browser = playwright.chromium.launch(**options)
+ for width in (360, 768, 1440):
+ for theme in ('light', 'dark'):
+ context = browser.new_context(
+ viewport={'width': width, 'height': 960},
+ color_scheme=theme,
+ locale='zh-CN',
+ reduced_motion='reduce',
+ )
+ page = context.new_page()
+ errors = []
+ page.on('pageerror', lambda error, errors=errors: errors.append(str(error)))
+ page.add_init_script(
+ "window.cspErrors=[];document.addEventListener('securitypolicyviolation',"
+ 'e=>cspErrors.push(e.violatedDirective));'
+ )
+ page.goto(origin + '/cold', wait_until='networkidle')
+ first = page.locator('.post-title a').first
+ expect(first).to_have_text(POSTS[0]['title'])
+ verify(page)
+ assert page.locator('.posts .post-excerpt').count() == len(POSTS)
+ # Direct navigation, browser back and forward must not lose the body.
+ first.click()
+ expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。')
+ verify(page)
+ page.go_back(wait_until='networkidle')
+ expect(page.locator('.post-title a').first).to_be_visible()
+ verify(page)
+ page.go_forward(wait_until='networkidle')
+ expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。')
+ verify(page)
+ page.screenshot(path=str(OUTPUT / f'post-{width}-{theme}.png'), full_page=True)
+ page.goto(origin + '/', wait_until='networkidle')
+ verify(page)
+ expect(page.locator('.latest')).to_be_visible()
+ assert (
+ page.locator('.latest').bounding_box()['y']
+ < page.locator('.activity').bounding_box()['y']
+ )
+ for target in page.locator('.post-title a, .section-link').all():
+ assert target.bounding_box()['height'] >= 44
+ page.screenshot(path=str(OUTPUT / f'home-{width}-{theme}.png'), full_page=True)
+ page.locator('.section-link').click()
+ expect(page.locator('.page-topic-index')).to_be_visible()
+ verify(page)
+ page.screenshot(
+ path=str(OUTPUT / f'topics-{width}-{theme}.png'), full_page=True
+ )
+ assert not errors, errors
+ assert not page.evaluate('window.cspErrors'), page.evaluate('window.cspErrors')
+ results.append({'width': width, 'theme': theme, 'passed': True})
+ context.close()
+ # Also exercise normal-motion navigation; reduced motion must not hide a failure.
+ context = browser.new_context(reduced_motion='no-preference')
+ page = context.new_page()
+ page.goto(origin + '/cold', wait_until='networkidle')
+ page.locator('.post-title a').first.click()
+ expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。')
+ page.go_back(wait_until='networkidle')
+ expect(page.locator('.post-title a').first).to_be_visible()
+ page.go_forward(wait_until='networkidle')
+ expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。')
+ context.close()
+ # The repair is server-rendered, not dependent on browser scripting.
+ context = browser.new_context(java_script_enabled=False)
+ page = context.new_page()
+ page.goto(origin + '/cold')
+ expect(page.locator('.post-title a').first).to_have_text(POSTS[0]['title'])
+ verify(page)
+ context.close()
+ browser.close()
+ finally:
+ server.shutdown()
+ server.server_close()
+ (OUTPUT / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n')
+ print(
+ f'{len(results)} viewport/theme cases plus normal-motion navigation and JavaScript-disabled first read passed.'
+ )
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/msg/transports/browser_style.py b/src/msg/transports/browser_style.py
index 2e8b6233..d623c4cc 100644
--- a/src/msg/transports/browser_style.py
+++ b/src/msg/transports/browser_style.py
@@ -270,17 +270,26 @@
.account-groups summary { color: var(--muted); font-size: 13px; }
.page-home #content > section:is(.activity, .latest, .channels) {
display: grid; grid-template-columns: 200px minmax(0, 1fr); column-gap: 56px;
- margin-block: 0; padding-block: 44px 52px; border-top: 1px solid var(--line);
+ margin-block: 0; padding-block: 40px 48px;
}
-.page-home #content > section:is(.activity, .latest, .channels) > h2 {
+.page-home #content > section:is(.activity, .latest, .channels) > :is(h2, .section-heading) {
grid-column: 1; grid-row: 1 / span 6; align-self: start;
position: sticky; top: calc(var(--header-height) + 24px);
- display: flex; align-items: center; gap: 10px; margin: 2px 0 24px; font-size: 15px; letter-spacing: -.01em;
+ margin: 2px 0 24px; font-size: 15px; letter-spacing: -.01em;
}
-.page-home #content > section:is(.activity, .latest, .channels) > h2::before {
+.page-home #content > section:is(.activity, .latest, .channels) > h2::before, .section-heading h2::before {
content: ''; flex: none; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--accent);
}
-.page-home #content > section:is(.activity, .latest, .channels) > :not(h2) { grid-column: 2; }
+.page-home #content > section:is(.activity, .latest, .channels) > :not(h2, .section-heading) { grid-column: 2; }
+.page-home #content > section:is(.activity, .latest, .channels) > h2 { display: flex; align-items: center; gap: 10px; }
+.section-heading { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
+.section-heading h2 { display: flex; align-items: center; gap: 10px; margin: 0; font: inherit; }
+.section-link {
+ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding-inline: 17px 8px;
+ color: var(--muted); font: 12px/1.5 var(--mono); text-decoration: none;
+}
+.section-link:hover { color: var(--accent); }
+.activity-unavailable { margin: 8px 0 32px; color: var(--muted); font-size: 13px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.stats > div { padding: 0 24px; border-inline-start: 1px solid var(--line); }
.stats > div:first-child { padding-inline-start: 0; border: 0; }
@@ -291,37 +300,26 @@
.stats span { display: block; margin-top: 6px; font-size: 13px; color: var(--muted); }
.activity-note { margin: 20px 0 0; font: 12px/1.6 var(--mono); }
-/* Latest posts ride one signal wire; each post is a node on it. */
-.posts { position: relative; list-style: none; margin: 0; padding: 0; }
-.posts li { padding-block: 16px 22px; }
-.page-home .posts { padding-inline-start: 34px; }
-.page-home .posts::before {
- content: ''; position: absolute; inset-inline-start: 5px; top: 30px; bottom: 0; width: 1px;
- background: linear-gradient(var(--line-strong) calc(100% - 64px), transparent);
-}
-.page-home .posts::after {
- content: ''; position: absolute; inset-inline-start: 5px; top: 30px; width: 1px; height: 72px;
- background: linear-gradient(transparent, var(--accent)); opacity: 0; pointer-events: none;
-}
-.page-home .posts li { position: relative; }
-.page-home .posts li::before {
- content: ''; position: absolute; inset-inline-start: -34px; top: 24px; width: 11px; height: 11px;
- border-radius: 50%; background: var(--bg); border: 1.5px solid var(--line-strong);
-}
-.page-home .posts li:hover::before { background: var(--accent); border-color: var(--accent); }
-.post-title { display: flex; justify-content: space-between; align-items: baseline; gap: 8px 28px; }
+/* Reading-first index: whitespace, a strong title and one quiet metadata row. */
+.posts { list-style: none; margin: 0; padding: 0; min-width: 0; }
+.posts li { padding: 0; }
+.posts li + li { margin-top: 30px; }
+.post-preview { position: relative; padding: 4px 0; }
+.post-title { margin: 0; font-size: inherit; }
.post-title a {
- display: inline-block; padding-block: 10px; margin-block: -10px;
- color: var(--fg); font: 600 19px/1.4 var(--sans); letter-spacing: -.015em; text-decoration: none;
+ display: inline-flex; align-items: center; min-height: 44px; max-width: 100%;
+ color: var(--fg); font: 600 clamp(19px, 2vw, 23px)/1.5 var(--sans);
+ letter-spacing: -.025em; text-decoration: none; text-wrap: pretty;
}
.post-title a:hover { color: var(--accent); }
-.post-engagement { color: var(--muted); font-size: 13px; }
-.post-engagement .muted { font-size: 13px; }
-.posts p { color: var(--muted); font-size: 15px; line-height: 1.7; margin: 4px 0 0; max-width: 68ch; }
+.post-engagement { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.post-engagement .muted, .post-engagement .reply-link { color: var(--muted); font-size: 12px; }
+.post-engagement .reply-link:hover { color: var(--accent); }
+.post-engagement time { font-family: var(--mono); font-size: 11.5px; }
+.posts .post-excerpt { color: var(--muted); font-size: 15px; line-height: 1.8; margin: 5px 0 4px; max-width: 66ch; }
.empty-state {
- margin: 0; padding: 28px; border: 1px dashed var(--line-strong); border-radius: var(--radius-panel);
- color: var(--muted); font-size: 15px; text-align: center;
+ margin: 0; padding: 24px 0; color: var(--muted); font-size: 15px; line-height: 1.8;
}
.notice {
display: flex; align-items: flex-start; gap: 12px; margin-block: 16px; padding: 14px 18px;
@@ -360,7 +358,7 @@
footer nav a { min-width: 44px; justify-content: center; padding-inline: 10px; color: var(--muted); font-size: 13px; }
footer p { margin: 0; font: 12px/1.6 var(--mono); }
-.page-topic-index .prose > h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.45; margin-block: 44px 6px; }
+.page-topic-index .prose > h2 { font-size: clamp(20px, 2.2vw, 25px); font-weight: 600; line-height: 1.5; letter-spacing: -.02em; margin-block: 40px 6px; }
.page-topic-index .prose > h2 > a, .page-topic-index .prose > p > a[href*="post_cursor="] {
display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; max-width: 100%;
}
@@ -398,7 +396,7 @@
/* The byline sits under the title as one quiet row. */
.post-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 0 20px;
- margin: -6px 0 36px; padding-bottom: 14px; border-bottom: 1px solid var(--line);
+ margin: -2px 0 40px; padding-bottom: 4px;
color: var(--muted); font-size: 13.5px;
}
.post-meta p { margin: 0; line-height: 44px; }
@@ -718,7 +716,8 @@
@media (max-width: 860px) {
.page-home #content > section:is(.activity, .latest, .channels) { display: block; padding-block: 36px 40px; }
- .page-home #content > section:is(.activity, .latest, .channels) > h2 { position: static; margin-bottom: 24px; }
+ .page-home #content > section:is(.activity, .latest, .channels) > :is(h2, .section-heading) { position: static; margin-bottom: 20px; }
+ .section-heading { flex-direction: row; align-items: center; justify-content: space-between; }
}
@media (max-width: 640px) {
:root { --header-height: 60px; }
@@ -740,9 +739,9 @@
.account:has(> .primary) { flex-direction: column-reverse; }
.stats > div { padding-inline: 14px; }
.stats strong { font-size: 28px; }
- .page-home .posts { padding-inline-start: 26px; }
- .page-home .posts li::before { inset-inline-start: -26px; }
- .post-title { align-items: start; flex-direction: column; gap: 2px; }
+ .posts li + li { margin-top: 24px; }
+ .posts .post-excerpt { font-size: 14px; }
+ .post-engagement { gap: 0 14px; }
.preference-row select { width: 150px; }
.post-meta { gap: 0 14px; }
.post-meta dl { grid-template-columns: 1fr; gap: 2px; }
@@ -775,14 +774,9 @@
transition: color .18s ease, background-color .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.primary::after, .thread-branch-chevron, .settings-chevron { transition: transform .28s var(--ease-out); }
- .thread-node::before, .page-home .posts li::before { transition: background-color .2s ease, border-color .2s ease, box-shadow .2s ease; }
+ .thread-node::before { transition: background-color .2s ease, border-color .2s ease, box-shadow .2s ease; }
.brand:is(:hover, :focus-visible) > svg { animation: msg-logo-turn .7s cubic-bezier(.22, 1, .36, 1); }
.preferences[open] .preference-fields, .account-menu[open] .account-menu-links { animation: msg-pop .26s var(--ease-out); }
- .page-home .posts::after { animation: msg-signal-run 1.9s linear .35s 1 both; }
- .page-home .posts li::before {
- animation: msg-node-ping 1.1s var(--ease-out) backwards;
- animation-delay: calc(.3s + 1.9s * (var(--i, 0) + .15) / var(--n, 4));
- }
.token-art[data-running] .token {
animation: token-assemble 12s cubic-bezier(.22, 1, .36, 1) infinite;
animation-delay: var(--phase); animation-play-state: paused;
@@ -796,16 +790,6 @@
@keyframes msg-pop {
from { opacity: 0; transform: translateY(-6px) scale(.98); }
}
-@keyframes msg-signal-run {
- 0% { top: 0; opacity: 0; }
- 12%, 82% { opacity: 1; }
- 100% { top: calc(100% - 72px); opacity: 0; }
-}
-@keyframes msg-node-ping {
- 0% { background: var(--bg); border-color: var(--line-strong); }
- 25% { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 5px var(--accent-soft); }
- 100% { background: var(--bg); border-color: var(--line-strong); box-shadow: 0 0 0 0 transparent; }
-}
@keyframes token-assemble {
0%, 12%, 100% { transform: translate(var(--dx), var(--dy)) rotate(var(--turn)); opacity: .38; }
38%, 65% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
diff --git a/src/msg/transports/home_page.py b/src/msg/transports/home_page.py
index 54fe4c82..25c1a98d 100644
--- a/src/msg/transports/home_page.py
+++ b/src/msg/transports/home_page.py
@@ -97,6 +97,43 @@ def account_navigation(account, *, compact=False):
return links
+def latest_posts_html(index, statuses=None):
+ """Render the independent, already-authorized post index, never false empty data."""
+ parts = [
+ ' 主帖列表暂时无法读取,请稍后刷新。 No public posts yet. {escape(item["excerpt"])} Sign in to view your inbox and direct messages. Confirm the approval code using your CLI.Latest posts
'
+ 'Topics'
+ '')
+ for item in index['items']:
+ path = escape(quote(item['path'], safe='/@*&'), quote=True)
+ title = escape(str(item.get('title', item['name'])))
+ stamp = display_time(item['created_at'])
+ parts.extend([
+ '
')
+ parts.append('{title}
',
+ ])
+ if item.get('excerpt'):
+ parts.append(f'
' - 'Statistics and latest posts are temporarily unavailable.
' + '' + '统计暂时无法读取。 / Site activity is temporarily unavailable.
' ) else: parts.append( @@ -206,35 +252,6 @@ def link(label, path): parts.append( f'{escape(data["date"])} · {escape(data["timezone"])}
' ) - parts.append( - '{escape(item["excerpt"])}
') - parts.append('主帖列表暂时无法读取,请稍后刷新。
' - if data.get('latest_unavailable') - else 'No public posts yet.
' - ) - parts.append('Only channels you can read are listed. Sign in to include your private channels. '
diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py
index d80dde40..e9e3f700 100644
--- a/src/msg/transports/http_routes.py
+++ b/src/msg/transports/http_routes.py
@@ -2989,18 +2989,15 @@ async def decorate(value):
browser_html = 'text/html' in request.headers.get('accept', '').casefold()
renderer = home_html if browser_html else home_markdown
reply_status = {}
- if browser_html and home_data:
+ index = None
+ if browser_html:
from msg.transports.thread_browser import reply_statuses, root_post_index
index = await root_post_index(
service.executor.execute, service.settings.service_url, limit=5
)
- home_data = {
- **home_data,
- 'latest': index['items'],
- 'latest_unavailable': bool(index.get('error')),
- }
- latest = home_data.get('latest', [])[:5]
+ # Post reads must not depend on a cold/failed full-site summary.
+ latest = index['items'][:5]
statuses = await reply_statuses(
execute_packet,
[item['path'] for item in latest],
@@ -3021,14 +3018,14 @@ async def decorate(value):
getattr(service.settings, 'oauth', None), 'enabled', False
),
expired=request.scope.get('state', {}).get('msg_browser_expired', False),
- **({'reply_status': reply_status} if browser_html else {}),
+ **({'reply_status': reply_status, 'post_index': index} if browser_html else {}),
)
headers = {
**(HOME_BROWSER_HEADERS if browser_html else BASE_HEADERS),
'Content-Length': str(len(payload)),
'Cache-Control': 'private, no-store'
if request.headers.get('cookie')
- or (browser_html and (home_data or {}).get('latest_unavailable'))
+ or (browser_html and (home_data is None or index.get('error')))
else 'private, no-cache',
'Vary': 'Accept, Cookie',
'X-Msg-Home-Snapshot': 'unavailable'
diff --git a/src/msg/transports/thread_browser.py b/src/msg/transports/thread_browser.py
index 6b4345ed..d0a16652 100644
--- a/src/msg/transports/thread_browser.py
+++ b/src/msg/transports/thread_browser.py
@@ -124,7 +124,7 @@ async def titled(item):
source='manual',
)
)
- title = item['name']
+ preview = {'title': item['name'], 'excerpt': ''}
if response.error and response.error.code in {
'permission_denied',
'not_found',
@@ -134,10 +134,10 @@ async def titled(item):
}:
return None
if not response.error:
- title = thread_preview({**item, 'content': wire(response.data)['text']})['title']
- if title == 'Untitled post':
- title = '未命名主帖'
- return {**item, 'path': '/*' + hex_id(item['id']), 'title': title}
+ preview = thread_preview({**item, 'content': wire(response.data)['text']})
+ if preview['title'] == 'Untitled post':
+ preview['title'] = '未命名主帖'
+ return {**item, 'path': '/*' + hex_id(item['id']), **preview}
return {
**page,
diff --git a/tests/test_browser_refinement.py b/tests/test_browser_refinement.py
index d8812a80..93dc330c 100644
--- a/tests/test_browser_refinement.py
+++ b/tests/test_browser_refinement.py
@@ -92,14 +92,12 @@ def test_home_counters_are_translated_without_invented_activity():
parsed = Elements(html)
markers = {a.get('data-i18n') for _, a in parsed.tags}
assert {'public_posts', 'today', 'users', 'no_posts'} <= markers
- assert (
- 'Statistics and latest posts are temporarily unavailable.' not in html.split('',
+ 'excerpt': '',
+ 'path': '/main/post',
+ 'created_at': '2026-10-09T14:00:00Z',
+ 'view_count': 2,
+ }
+ page = home_html(None, post_index={'items': [item]}).decode().split('