diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py
index f59ff06..8d12776 100644
--- a/src/apps/billboard/tests/integrated/test_views.py
+++ b/src/apps/billboard/tests/integrated/test_views.py
@@ -85,6 +85,73 @@ class BillboardViewTest(TestCase):
self.assertIsNone(response.context["recent_room"])
self.assertEqual(list(response.context["recent_events"]), [])
+ # ── recent_buds + recent_notes (applet feed) ──────────────────────
+ # Mirrors the recent_posts pattern: each in-grid applet now lists
+ # its own most-recent items (capped at 3) plus an empty-state row.
+
+ def test_passes_recent_buds_context(self):
+ first = User.objects.create(email="first-bud@test.io")
+ second = User.objects.create(email="second-bud@test.io")
+ self.user.buds.add(first)
+ self.user.buds.add(second)
+ response = self.client.get("/billboard/")
+ # Most-recently-added bud is first
+ self.assertEqual(list(response.context["recent_buds"]), [second, first])
+
+ def test_recent_buds_capped_at_3(self):
+ added = [
+ User.objects.create(email=f"bud{i}@test.io") for i in range(5)
+ ]
+ for u in added:
+ self.user.buds.add(u)
+ response = self.client.get("/billboard/")
+ recent = list(response.context["recent_buds"])
+ self.assertEqual(len(recent), 3)
+ # Newest three in newest-first order
+ self.assertEqual(recent, list(reversed(added))[:3])
+
+ def test_recent_buds_empty_when_no_buds(self):
+ response = self.client.get("/billboard/")
+ self.assertEqual(list(response.context["recent_buds"]), [])
+
+ def test_passes_recent_notes_context(self):
+ Note.objects.create(
+ user=self.user, slug="stargazer",
+ earned_at=timezone.now() - timezone.timedelta(days=2),
+ )
+ Note.objects.create(
+ user=self.user, slug="super-schizo",
+ earned_at=timezone.now(),
+ )
+ response = self.client.get("/billboard/")
+ slugs = [n.slug for n in response.context["recent_notes"]]
+ # Most-recently-earned first
+ self.assertEqual(slugs, ["super-schizo", "stargazer"])
+
+ def test_recent_notes_capped_at_3(self):
+ slugs = ["stargazer", "super-schizo", "super-nomad", "ladidah", "doodah"]
+ base = timezone.now()
+ for i, slug in enumerate(slugs):
+ Note.objects.create(
+ user=self.user, slug=slug,
+ earned_at=base - timezone.timedelta(hours=i),
+ )
+ response = self.client.get("/billboard/")
+ names = [n.slug for n in response.context["recent_notes"]]
+ self.assertEqual(len(names), 3)
+ # Newest-first; oldest two trimmed
+ self.assertEqual(names, slugs[:3])
+
+ def test_recent_notes_excludes_other_users(self):
+ other = User.objects.create(email="other-note@test.io")
+ Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
+ response = self.client.get("/billboard/")
+ self.assertEqual(list(response.context["recent_notes"]), [])
+
+ def test_recent_notes_empty_when_no_notes(self):
+ response = self.client.get("/billboard/")
+ self.assertEqual(list(response.context["recent_notes"]), [])
+
class SaveScrollPositionViewTest(TestCase):
def setUp(self):
@@ -602,3 +669,31 @@ class AbandonPostViewTest(TestCase):
self.client.post(reverse("billboard:abandon_post", args=[admin_post.id]))
admin_post.refresh_from_db()
self.assertIn(self.invitee, admin_post.shared_with.all())
+
+
+class AnonymousPostViewerTest(TestCase):
+ """view_post has no @login_required (Percival ch.18 anonymous-post lab —
+ ownerless Posts are viewable by anyone). The gear menu's NVM target
+ reverses `my_posts user_id=request.user.id`, which previously exploded
+ w. NoReverseMatch when request.user.id was None (anonymous user).
+ Gear menu must be gated on is_authenticated; the post body still renders
+ for anonymous viewers of ownerless posts."""
+
+ def setUp(self):
+ from apps.billboard.models import Post
+ # Ownerless Post — matches the Percival anonymous-share contract
+ # exercised by apps.dashboard.tests.integrated.test_views.SharePostTest.
+ # No Line needed; the empty post still exercises the template render.
+ self.post = Post.objects.create(title="Public-ish")
+
+ def test_anonymous_can_view_ownerless_post_without_500(self):
+ response = self.client.get(
+ reverse("billboard:view_post", args=[self.post.id])
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_anonymous_gets_no_gear_menu(self):
+ response = self.client.get(
+ reverse("billboard:view_post", args=[self.post.id])
+ )
+ self.assertNotIn(b"id_post_menu", response.content)
diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py
index b0ee6d5..8d3ed14 100644
--- a/src/apps/billboard/views.py
+++ b/src/apps/billboard/views.py
@@ -31,6 +31,23 @@ def _recent_posts(user, limit=3):
)
+def _recent_buds(user, limit=3):
+ """Most-recently-added buds, newest first. M2M has no explicit through
+ model, so we sort the auto-through table by its monotonic `id`."""
+ through = User.buds.through # type: ignore[attr-defined]
+ rows = (
+ through.objects
+ .filter(from_user=user)
+ .select_related("to_user")
+ .order_by("-id")[:limit]
+ )
+ return [r.to_user for r in rows]
+
+
+def _recent_notes(user, limit=3):
+ return user.notes.order_by("-earned_at")[:limit]
+
+
def _billboard_context(user):
my_rooms = rooms_for_user(user).order_by("-created_at")
@@ -73,6 +90,8 @@ def _billboard_context(user):
"applets": applet_context(user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(user),
+ "recent_buds": _recent_buds(user),
+ "recent_notes": _recent_notes(user),
}
@@ -251,6 +270,8 @@ def new_post(request):
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
+ context["recent_buds"] = _recent_buds(request.user)
+ context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/billboard/billboard.html", context)
diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss
index 6fcbe49..f2d3058 100644
--- a/src/static_src/scss/_billboard.scss
+++ b/src/static_src/scss/_billboard.scss
@@ -1,25 +1,80 @@
-// ── My Posts applet ────────────────────────────────────────────────────────
+// ── Shared scrollable applet list (.applet-list) ───────────────────────────
+// In-grid applet partials (My Posts, My Buds, My Notes, My Scrolls, My Games)
+// + the dedicated applet-list pages (billbuds, billposts) all wrap items in a
+// `
`. The list rules live at top level so the same
+// item-entry styling applies in both surfaces; per-context wrappers below
+// handle flex sizing so the list scrolls inside its parent's aperture.
-#id_applet_my_posts {
+.applet-list {
+ list-style: none;
+ margin: 0;
+ padding: 0 0.75rem 0 0;
+ min-height: 0;
+ overflow-y: auto;
+
+ // Flex-column lets the empty-state entry fill the aperture so it can
+ // centre vertically. Fires only when the list is entirely empty —
+ // as soon as a real .applet-list-entry lands, layout reverts to the
+ // default left-aligned vertical stack.
+ &:has(> .applet-list-entry--empty) {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+// Empty-state filler (`No yet.` rows). Centres in any flex-column
+// parent — the .applet-list above OR a `display: flex; flex-direction:
+// column` applet section directly (e.g. #id_applet_most_recent_scroll
+// when no Room has events yet).
+.applet-list-entry--empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ opacity: 0.6;
+ font-style: italic;
+ margin: 0;
+}
+
+.applet-list-entry {
+ padding: 0.4rem 0;
+
+ .bud-name { font-weight: bold; opacity: 0.85; }
+
+ a {
+ color: rgba(var(--terUser), 1);
+ text-decoration: none;
+ font-weight: bold;
+ transition: text-shadow 0.15s ease;
+
+ &:hover,
+ &:active {
+ color: rgba(var(--ninUser), 1);
+ text-shadow: 0 0 0.55rem rgba(var(--terUser), 0.7);
+ }
+ }
+}
+
+.applet-list-buffer {
+ flex-shrink: 0;
+ height: 0.5rem;
+}
+
+// In-grid applet sections: flex-column so the .applet-list can flex:1
+// and scroll within the applet box. Left-aligned items across the
+// board (My Games used to centre — symmetrised w. the rest 2026-05-12).
+
+#id_applet_my_posts,
+#id_applet_my_buds,
+#id_applet_my_scrolls,
+#id_applet_notes {
display: flex;
flex-direction: column;
- .my-posts-container {
+ .applet-list {
flex: 1;
- min-height: 0;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
- &::-webkit-scrollbar { display: none; }
- mask-origin: padding-box;
- mask-clip: padding-box;
- mask-image: linear-gradient(
- to bottom,
- transparent 0%,
- black 5%,
- black 85%,
- transparent 100%
- );
+ padding-top: 0.25rem;
}
}
@@ -235,40 +290,9 @@ body.page-billposts {
display: flex;
flex-direction: column;
- .applet-list {
- list-style: none;
- margin: 0;
- padding: 0 0.75rem 0 0;
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- }
-
- .applet-list-entry {
- padding: 0.4rem 0;
-
- .bud-name { font-weight: bold; opacity: 0.85; }
-
- &--empty { opacity: 0.6; font-style: italic; }
-
- a {
- color: rgba(var(--terUser), 1);
- text-decoration: none;
- font-weight: bold;
- transition: text-shadow 0.15s ease;
-
- &:hover,
- &:active {
- color: rgba(var(--ninUser), 1);
- text-shadow: 0 0 0.55rem rgba(var(--terUser), 0.7);
- }
- }
- }
-
- .applet-list-buffer {
- flex-shrink: 0;
- height: 0.5rem;
- }
+ // .applet-list / .applet-list-entry / .applet-list-buffer rules
+ // live at top level (above) so they apply to in-grid applets too.
+ .applet-list { flex: 1; }
}
// Side-by-side in landscape; stacked in portrait (default).
@@ -302,20 +326,6 @@ body.page-billposts {
}
}
-// ── Notes applet — vertical title ─────────────────────────────────────────
-
-#id_applet_notes {
- h2 {
- writing-mode: vertical-rl;
- transform: rotate(180deg);
- margin: 0;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-}
-
// ── Most Recent Scroll applet — scrollable drama feed ─────────────────────
#id_applet_most_recent_scroll {
@@ -365,22 +375,5 @@ body.page-billposts {
}
}
-// ── My Scrolls list ────────────────────────────────────────────────────────
-
-#id_applet_my_scrolls {
- .scroll-list {
- list-style: none;
- padding: 0;
- margin: 0;
- overflow-y: auto;
-
- li {
- padding: 0.25rem 0;
- border-bottom: 1px solid rgba(var(--priUser), 0.15);
-
- &:last-child { border-bottom: none; }
-
- a { text-decoration: none; }
- }
- }
-}
+// My Scrolls now rides the shared `.applet-list` rule above (lifted out of
+// `.applet-list-page .applet-scroll`). Old `.scroll-list` styling removed.
diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss
index 3085f69..d9b4e4e 100644
--- a/src/static_src/scss/_gameboard.scss
+++ b/src/static_src/scss/_gameboard.scss
@@ -83,33 +83,18 @@ body.page-gameboard {
}
}
-#id_applet_new_game,
+#id_applet_new_game {
+ display: flex;
+ flex-direction: column;
+}
+
#id_applet_my_games {
display: flex;
flex-direction: column;
- ul {
+ .applet-list {
flex: 1;
- list-style: none;
- padding: 0;
- margin: 0;
- display: flex;
- align-items: center;
- justify-content: center;
-
- small {
- opacity: 0.5;
- font-style: italic;
- }
- }
-}
-
-#id_applet_my_games {
-
- ul {
- display: flex;
- flex-direction: column;
-
+ padding-top: 0.25rem;
}
}
diff --git a/src/templates/apps/applets/_partials/_applet-grid-list.html b/src/templates/apps/applets/_partials/_applet-grid-list.html
new file mode 100644
index 0000000..fa2e87a
--- /dev/null
+++ b/src/templates/apps/applets/_partials/_applet-grid-list.html
@@ -0,0 +1,17 @@
+{# Scrollable shared by in-grid applet partials (My Buds / My Notes / #}
+{# My Posts / My Scrolls / My Games) AND by the dedicated billposts / #}
+{# billbuds pages that wrap it in _applet-list-shell.html. #}
+{# #}
+{# Parameters: #}
+{# list_items — iterable rendered into the list #}
+{# list_item_template — partial rendering each - ; receives `item` #}
+{# list_empty — fallback string for the empty-state row #}
+{# list_id — optional `id=` for the
(e.g. "id_buds_list") #}
+
+ {% for item in list_items %}
+ {% include list_item_template %}
+ {% empty %}
+ - {{ list_empty|default:"Nothing here yet." }}
+ {% endfor %}
+
+
diff --git a/src/templates/apps/applets/_partials/_applet-list-shell.html b/src/templates/apps/applets/_partials/_applet-list-shell.html
index 354c532..a4d88a0 100644
--- a/src/templates/apps/applets/_partials/_applet-list-shell.html
+++ b/src/templates/apps/applets/_partials/_applet-list-shell.html
@@ -1,7 +1,9 @@
{# Shared applet-scroll-style list section — vertical-title on the #}
{# left + scrollable aperture. Inclusion shell (NOT a base template) #}
{# so a single page can invoke it more than once (e.g. my_posts.html #}
-{# stacks "My Posts" + "Posts shared with me"). #}
+{# stacks "My Posts" + "Posts shared with me"). The inner is in the #}
+{# shared `_applet-grid-list.html` partial so the in-grid applet #}
+{# partials can reuse it without the .applet-scroll wrapper. #}
{# #}
{# Parameters: #}
{# shell_title — vertical-rotated heading text (string) #}
@@ -12,12 +14,5 @@
{# shell_empty — text for the {% empty %} fallback row #}
diff --git a/src/templates/apps/billboard/_partials/_applet-most-recent-scroll.html b/src/templates/apps/billboard/_partials/_applet-most-recent-scroll.html
index 2d194b6..dc6a719 100644
--- a/src/templates/apps/billboard/_partials/_applet-most-recent-scroll.html
+++ b/src/templates/apps/billboard/_partials/_applet-most-recent-scroll.html
@@ -23,7 +23,7 @@
{% endfor %}
{% else %}
- No recent activity.
+ No recent activity.
{% endif %}