# Two topics on a card, and the right two

2026-08-10. Ernest: *"The topic that shows on the grid card needs to be the one
with the latest reply and add two topics that show instead of just one."*

---

## What it was doing

The card read `forums.last_topic_id` — a pointer the counter service maintains,
which does already mean "the topic whose most recent post is the newest". So
the *ordering* half was arguably already right, and the reason it does not feel
right is the part the pointer cannot do:

- **It is one topic.** A pointer holds one id. Two is not a bigger pointer.
- **It goes blank rather than falling back.** Deleting the most recent topic
  leaves the pointer aimed at a deleted row, and the card shows "No posts yet"
  on a forum with four hundred posts in it. There is a comment in the
  controller explaining this as the honest answer; with a real query it is not
  the only answer available.
- **It is only as fresh as the last `refresh()`.** Anything that moves a post
  without going through `ForumCounters` leaves the card wrong, and nothing
  notices.

Asking the question directly fixes all three at once.

## One query, whatever the size of the index

```sql
ROW_NUMBER() OVER (PARTITION BY forum_id ORDER BY last_post_at DESC)
```

`LatestTopics::forForums()` runs exactly one of these for the whole page, which
is the same standard the controller already holds itself to — *"three queries
here, whatever the size of the list"* — and the same one the widgets are held
to. A query per card is forty queries on a real board.

The existing index `(forum_id, is_pinned, last_post_at)` covers it.

**Pinned topics get no special treatment here.** A card is a "what is happening"
summary, not the forum's own listing — a pin that has been quiet for a month is
exactly what somebody scanning the index does not need to see. Inside the forum,
pins still sort first.

The base clause is the one `Streams::clauseFor()` uses — not deleted, not
hidden — plus `owner_type IS NULL`, because a blog entry's comment topic is not
a forum discussion. 🚨 Anything added to this must keep that base, or a card
becomes a way to see content that browsing would not show.

## 🚨 The card had to stop being one big anchor

Two topic titles inside a card that is itself an `<a>` is an anchor inside an
anchor. The parser closes the outer one early and tears the layout in half, and
`tests/Engine/TemplateSanityTest.php` exists because that exact bug was made
twice in one day.

So the card is now an `<article>`, and the forum title carries a stretched
pseudo-element that covers the card — the same pattern the topic rows already
use, and the same one that makes the whole row pressable on a phone. The two
topic links sit above it with `z-index: 2`.

That is also a small improvement on what was there: the topics are now
individually clickable, where before the whole card went to the forum and the
title on it was decoration.

**The rule that comes with the pattern:** anything else clickable added to a
card needs `z-index: 2`, or the stretched link swallows it. The stylesheet says
so where the rule is written.
