Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 544e9c2cff7bc4f99708d4cc7891946bb798614f)
+++ src/views/pages/news.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
@@ -88,72 +88,10 @@
     </div>
   <% } else { %>
-    <ol class="tl-feed">
+    <ol class="tl-feed" id="tl-feed">
       <% timeline.forEach(function(p){ %>
-        <li class="tl-item">
-          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%= p.reblog_name %></strong> <%= t('tl.boosted') %></div><% } %>
-          <div class="tl-head">
-            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 96) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
-            <span class="tl-id">
-              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
-              <span class="tl-handle"><%= p.author_handle %></span>
-            </span>
-            <% if (p.published || p.created_at) { %><time class="tl-time"><%= formatDateTime(p.published || p.created_at) %></time><% } %>
-          </div>
-
-          <%
-            var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; }
-            var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); });
-            var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); });
-            var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); });
-            var _nsfwVisual = !!p.nsfw && (imgs.length || vids.length || auds.length);
-            var _nsfwText = !!p.nsfw && !(imgs.length || vids.length || auds.length);
-          %>
-          <% if (_nsfwText) { %>
-            <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- p.content %></div>
-          <% } else { %>
-            <div class="tl-content"><%- p.content %></div>
-          <% } %>
-          <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
-          <% if (imgs.length) { %>
-            <div class="tl-media">
-              <% imgs.forEach(function(m){ %>
-                <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
-              <% }); %>
-            </div>
-          <% } %>
-          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
-          <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already
-                covers these tracks, so don't ALSO render the raw Audio attachments as bare
-                players here. Posts without a Klonkt embed (e.g. a plain remote audio) keep them. */ %>
-          <% if (!p.embedHtml && !p.embedUrl) { auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
-          <% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></div></div><% } %>
-
-          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
-          <% if (p.embedUrl) { %><a class="tl-embed-open" href="<%= p.embedUrl %>" target="_blank" rel="noopener">▶ <%= t('tl.open_player') %></a><% } %>
-
-          <% if (p.poll) { var _pl=p.poll; var _tot=_pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _voteable=!_pl.closed&&!_pl.voted; %>
-          <div class="tl-poll">
-            <% if (_voteable) { %>
-              <form method="post" action="/news/vote" class="tl-poll-form">
-                <input type="hidden" name="note" value="<%= p.id %>">
-                <% _pl.options.forEach(function(o){ %><label class="tl-poll-choice"><input type="<%= _pl.multiple?'checkbox':'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label><% }); %>
-                <button type="submit" class="btn btn-primary tl-poll-btn"><%= t('poll.vote') %></button>
-              </form>
-            <% } else { _pl.options.forEach(function(o){ var _pct=_tot?Math.round((o.count||0)*100/_tot):0; var _mine=_pl.voted&&(Array.isArray(_pl.voted)?_pl.voted.indexOf(o.name)>=0:_pl.voted===o.name); %><div class="tl-poll-res<%= _mine?' is-mine':'' %>"><span class="tl-poll-fill" style="width:<%= _pct %>%"></span><span class="tl-poll-name"><%= _mine?'✓ ':'' %><%= o.name %></span><span class="tl-poll-pct"><%= _pct %>%</span></div><% }); } %>
-            <div class="tl-poll-foot"><%= (_pl.voters!=null?_pl.voters:_tot) %> <%= t('poll.votes') %><% if(_pl.closed){ %> · <%= t('poll.closed') %><% } %></div>
-          </div>
-          <% } %>
-
-          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-
-          <div class="tl-actions">
-            <form method="post" action="/news/like" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
-            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
-            <button type="button" class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
-            <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
-          </div>
-        </li>
+        <%- include('../partials/tl-item', { p: p }) %>
       <% }); %>
     </ol>
+    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news' }) %>
   <% } %>
 </div>
Index: src/views/partials/load-more.ejs
===================================================================
--- src/views/partials/load-more.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
+++ src/views/partials/load-more.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
@@ -0,0 +1,16 @@
+<%
+  // Reusable "Load more" control. On the full page it sits after the feed; the
+  // append response re-emits it with oob=true so htmx swaps the fresh button
+  // (next offset) in place of the old one, and drops it on the last page.
+  var _oob = (typeof oob !== 'undefined' && oob);
+  var _target = (typeof moreTarget !== 'undefined' && moreTarget) ? moreTarget : '#feed';
+  var _path = (typeof morePath !== 'undefined' && morePath) ? morePath : '';
+  var _base = (typeof moreBase !== 'undefined' && moreBase) ? moreBase : '';
+%>
+<div id="load-more" class="load-more-wrap"<% if (_oob) { %> hx-swap-oob="true"<% } %>>
+  <% if (hasMore) { %>
+    <button type="button" class="btn load-more-btn"
+      hx-get="<%= _base %><%= _path %>?append=1&offset=<%= nextOffset %>"
+      hx-target="<%= _target %>" hx-swap="beforeend"><%= t('feed.load_more') %></button>
+  <% } %>
+</div>
Index: src/views/partials/news-append.ejs
===================================================================
--- src/views/partials/news-append.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
+++ src/views/partials/news-append.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
@@ -0,0 +1,2 @@
+<% timeline.forEach(function(p){ %><%- include('tl-item', { p: p }) %><% }); %>
+<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news', oob: true }) %>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 544e9c2cff7bc4f99708d4cc7891946bb798614f)
+++ src/views/partials/shared-styles.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
@@ -183,3 +183,13 @@
 .htmx-request .pcms-loading { display: block; }
 @keyframes spin { to { transform: rotate(360deg); } }
+
+/* Load more (Solo, Cirkel, News, Messages) — page size 72, htmx append. */
+.load-more-wrap { display: flex; justify-content: center; margin: 1.5rem 0 .5rem; }
+.load-more-wrap:empty { margin: 0; }
+.load-more-btn { padding: .6rem 1.4rem; border-radius: 999px; cursor: pointer;
+  font: inherit; font-weight: 600; color: var(--ink, inherit);
+  background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
+  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
+.load-more-btn:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
+.load-more-btn.htmx-request { opacity: .6; pointer-events: none; }
 </style>
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
+++ src/views/partials/tl-item.ejs	(revision 7b04d3b709bae5f43a5c7f73f2d86123a46aa1d4)
@@ -0,0 +1,64 @@
+<li class="tl-item">
+          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%= p.reblog_name %></strong> <%= t('tl.boosted') %></div><% } %>
+          <div class="tl-head">
+            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 96) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
+            <span class="tl-id">
+              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
+              <span class="tl-handle"><%= p.author_handle %></span>
+            </span>
+            <% if (p.published || p.created_at) { %><time class="tl-time"><%= formatDateTime(p.published || p.created_at) %></time><% } %>
+          </div>
+
+          <%
+            var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; }
+            var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); });
+            var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); });
+            var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); });
+            var _nsfwVisual = !!p.nsfw && (imgs.length || vids.length || auds.length);
+            var _nsfwText = !!p.nsfw && !(imgs.length || vids.length || auds.length);
+          %>
+          <% if (_nsfwText) { %>
+            <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- p.content %></div>
+          <% } else { %>
+            <div class="tl-content"><%- p.content %></div>
+          <% } %>
+          <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
+          <% if (imgs.length) { %>
+            <div class="tl-media">
+              <% imgs.forEach(function(m){ %>
+                <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
+              <% }); %>
+            </div>
+          <% } %>
+          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
+          <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already
+                covers these tracks, so don't ALSO render the raw Audio attachments as bare
+                players here. Posts without a Klonkt embed (e.g. a plain remote audio) keep them. */ %>
+          <% if (!p.embedHtml && !p.embedUrl) { auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
+          <% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></div></div><% } %>
+
+          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
+          <% if (p.embedUrl) { %><a class="tl-embed-open" href="<%= p.embedUrl %>" target="_blank" rel="noopener">▶ <%= t('tl.open_player') %></a><% } %>
+
+          <% if (p.poll) { var _pl=p.poll; var _tot=_pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _voteable=!_pl.closed&&!_pl.voted; %>
+          <div class="tl-poll">
+            <% if (_voteable) { %>
+              <form method="post" action="/news/vote" class="tl-poll-form">
+                <input type="hidden" name="note" value="<%= p.id %>">
+                <% _pl.options.forEach(function(o){ %><label class="tl-poll-choice"><input type="<%= _pl.multiple?'checkbox':'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label><% }); %>
+                <button type="submit" class="btn btn-primary tl-poll-btn"><%= t('poll.vote') %></button>
+              </form>
+            <% } else { _pl.options.forEach(function(o){ var _pct=_tot?Math.round((o.count||0)*100/_tot):0; var _mine=_pl.voted&&(Array.isArray(_pl.voted)?_pl.voted.indexOf(o.name)>=0:_pl.voted===o.name); %><div class="tl-poll-res<%= _mine?' is-mine':'' %>"><span class="tl-poll-fill" style="width:<%= _pct %>%"></span><span class="tl-poll-name"><%= _mine?'✓ ':'' %><%= o.name %></span><span class="tl-poll-pct"><%= _pct %>%</span></div><% }); } %>
+            <div class="tl-poll-foot"><%= (_pl.voters!=null?_pl.voters:_tot) %> <%= t('poll.votes') %><% if(_pl.closed){ %> · <%= t('poll.closed') %><% } %></div>
+          </div>
+          <% } %>
+
+          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
+
+          <div class="tl-actions">
+            <form method="post" action="/news/like" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
+            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
+            <button type="button" class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
+            <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
+          </div>
+        </li>
