diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx
index 0f7399df..05fc6d2c 100644
--- a/app/src/features/chat/ChatView.tsx
+++ b/app/src/features/chat/ChatView.tsx
@@ -175,7 +175,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
)}
-
+
{list}
diff --git a/app/src/features/messages/Timeline.tsx b/app/src/features/messages/Timeline.tsx
index 4851bfbe..9494aa59 100644
--- a/app/src/features/messages/Timeline.tsx
+++ b/app/src/features/messages/Timeline.tsx
@@ -69,7 +69,7 @@ export function Timeline({
)}
{count === 0 && !loading && !error && {empty}
}
-
+
{messages.map((message) => (
))}
diff --git a/app/src/index.css b/app/src/index.css
index 36aa5f29..27edc1c5 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -11,6 +11,7 @@
@import "./effort.css";
@import "./motion-less.css";
@import "./paused.css";
+@import "./offscreen.css";
/* Colors are tokens so the light and dark themes swap in one place
(spec 15.3). State colors always go with an icon and a label. */
diff --git a/app/src/offscreen.css b/app/src/offscreen.css
new file mode 100644
index 00000000..33a036c3
--- /dev/null
+++ b/app/src/offscreen.css
@@ -0,0 +1,9 @@
+/* Long lists (the chat, a crew's messages) keep every row in the page.
+ Rows out of sight skip layout and paint until they scroll near, and
+ keep the size they last had so the scrollbar stays still. The newest
+ rows are left out: that is where cards arrive with a glow that would
+ be clipped, and where the owner looks. */
+.offscreen-rows > li:not(:nth-last-child(-n + 8)) {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 120px;
+}
diff --git a/docs/spec.md b/docs/spec.md
index 265817d7..d5a03544 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -731,6 +731,8 @@ Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem
O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s, menos com a janela fora de vista (minimizada, escondida perto do relógio ou aberta escondida no login do Windows): aí para, e é relido quando ela volta. Fora de vista, as animações também param onde estão (`paused.css`) e continuam quando a janela volta. O Windows pode não avisar a página quando o próprio app esconde a janela, então o app marca isso ele mesmo (`shell/visibility.ts`), e a janela voltar à frente desmarca.
+O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As linhas fora de vista não são diagramadas nem pintadas até chegarem perto da tela (`content-visibility: auto`, em `offscreen.css`), e guardam o tamanho que tiveram para a barra de rolagem não pular. As 8 mais novas ficam de fora: é onde os cartões chegam com o brilho de atenção, que a contenção cortaria.
+
- **Deliveries:** entram no store pela message (cada message tem uma). Vêm as 500 atualizadas mais recentemente e todas as mortas, depois cada `delivery.changed`.
- **Tasks:** todas, depois cada `task.changed`.
- **Chat e timeline** não ficam no store. Cada um carrega uma página (50) do que mostra, pede as anteriores sob demanda com `before` e acrescenta o que chega por notificação (`chat.item`, `chat.delta`, `message.created`).