buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD

- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
  - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
  - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
  - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
  - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
  - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
  - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
  - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
  - 841 ITs + 5 my_buds/my_posts FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 23:08:33 -04:00
parent 5f6002aa70
commit 246e45e55d
29 changed files with 552 additions and 443 deletions

View File

@@ -0,0 +1,40 @@
"""Rename the My Buddies applet → My Buds (slug + name).
UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the
parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed
the page-header band; BILLBUDS fits cleanly.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buddies")
except Applet.DoesNotExist:
return
applet.slug = "my-buds"
applet.name = "My Buds"
applet.save(update_fields=["slug", "name"])
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buds")
except Applet.DoesNotExist:
return
applet.slug = "my-buddies"
applet.name = "My Buddies"
applet.save(update_fields=["slug", "name"])
class Migration(migrations.Migration):
dependencies = [
("applets", "0006_rename_contacts_to_buddies"),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0 on 2026-05-09 03:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('billboard', '0005_line_admin_solicited'),
]
operations = [
migrations.AlterModelOptions(
name='line',
options={'ordering': ('created_at', 'id')},
),
]

View File

@@ -1,143 +0,0 @@
"""ITs for the My Buddies feature (User.buddies M2M + my_buddies view +
add_buddy JSON endpoint).
User.buddies is a self M2M (symmetrical=False) — adding Alice to Disco's
list does NOT auto-reciprocate. Implicit auto-add on shared events
(post-share, gate-invite) is layered separately in those views.
Privacy: add_buddy returns 200 with {buddy: null} when the email is
unregistered, so the response shape never leaks membership.
"""
from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User
class UserBuddiesM2MTest(TestCase):
"""The buddies field is asymmetric — A.buddies.add(B) doesn't
reciprocate to B.buddies, only to B.added_as_buddy."""
def setUp(self):
self.disco = User.objects.create(email="disco@test.io")
self.alice = User.objects.create(email="alice@test.io")
def test_add_buddy_one_way(self):
self.disco.buddies.add(self.alice)
self.assertIn(self.alice, self.disco.buddies.all())
self.assertNotIn(self.disco, self.alice.buddies.all())
def test_added_as_buddy_reverse_relation(self):
self.disco.buddies.add(self.alice)
self.assertIn(self.disco, self.alice.added_as_buddy.all())
def test_add_is_idempotent(self):
self.disco.buddies.add(self.alice)
self.disco.buddies.add(self.alice)
self.assertEqual(self.disco.buddies.count(), 1)
class MyBuddiesViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buddies.add(self.alice, self.bob)
def test_my_buddies_renders_template(self):
response = self.client.get(reverse("billboard:my_buddies"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_buddies.html")
def test_my_buddies_lists_users_buddies(self):
response = self.client.get(reverse("billboard:my_buddies"))
buddies = list(response.context["buddies"])
self.assertIn(self.alice, buddies)
self.assertIn(self.bob, buddies)
def test_my_buddies_does_not_list_others_buddies(self):
other = User.objects.create(email="other@test.io")
carol = User.objects.create(email="carol@test.io", username="carol")
other.buddies.add(carol)
response = self.client.get(reverse("billboard:my_buddies"))
self.assertNotIn(carol, list(response.context["buddies"]))
def test_my_buddies_redirects_anon_to_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_buddies"))
self.assertEqual(response.status_code, 302)
class AddBuddyViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
def test_add_registered_email_adds_to_buddies(self):
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(alice, self.user.buddies.all())
def test_add_returns_buddy_payload_with_username(self):
User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
body = response.json()
self.assertIsNotNone(body["buddy"])
self.assertEqual(body["buddy"]["username"], "alice")
def test_add_unregistered_email_returns_null_buddy(self):
"""Privacy: 200 with buddy=null so the response shape doesn't leak
whether the address is on the system."""
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "ghost@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["buddy"])
self.assertEqual(self.user.buddies.count(), 0)
def test_add_own_email_is_silent_noop(self):
"""Adding yourself: no buddy added, response carries buddy=null."""
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "me@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["buddy"])
self.assertNotIn(self.user, self.user.buddies.all())
def test_add_existing_buddy_is_idempotent(self):
alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buddies.add(alice)
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
# Still only one buddy entry — M2M dedup
self.assertEqual(self.user.buddies.count(), 1)
# Response still carries the buddy payload (so the JS can refresh
# an entry if a fast double-click bypassed the data-buddy-id guard).
self.assertIsNotNone(response.json()["buddy"])
def test_add_falls_back_to_email_when_no_username(self):
"""Buddy payload returns email when buddy.username is None — display
layer matches the navbar fallback (display_name filter)."""
User.objects.create(email="anon@test.io")
response = self.client.post(
reverse("billboard:add_buddy"),
data={"recipient": "anon@test.io"},
)
self.assertEqual(response.json()["buddy"]["username"], "anon@test.io")
def test_get_returns_405(self):
response = self.client.get(reverse("billboard:add_buddy"))
self.assertEqual(response.status_code, 405)

View File

@@ -0,0 +1,143 @@
"""ITs for the My Buds feature (User.buds M2M + my_buds view +
add_bud JSON endpoint).
User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's
list does NOT auto-reciprocate. Implicit auto-add on shared events
(post-share, gate-invite) is layered separately in those views.
Privacy: add_bud returns 200 with {bud: null} when the email is
unregistered, so the response shape never leaks membership.
"""
from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User
class UserBudsM2MTest(TestCase):
"""The buds field is asymmetric — A.buds.add(B) doesn't
reciprocate to B.buds, only to B.added_as_bud."""
def setUp(self):
self.disco = User.objects.create(email="disco@test.io")
self.alice = User.objects.create(email="alice@test.io")
def test_add_bud_one_way(self):
self.disco.buds.add(self.alice)
self.assertIn(self.alice, self.disco.buds.all())
self.assertNotIn(self.disco, self.alice.buds.all())
def test_added_as_bud_reverse_relation(self):
self.disco.buds.add(self.alice)
self.assertIn(self.disco, self.alice.added_as_bud.all())
def test_add_is_idempotent(self):
self.disco.buds.add(self.alice)
self.disco.buds.add(self.alice)
self.assertEqual(self.disco.buds.count(), 1)
class MyBudsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buds.add(self.alice, self.bob)
def test_my_buds_renders_template(self):
response = self.client.get(reverse("billboard:my_buds"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_buds.html")
def test_my_buds_lists_users_buds(self):
response = self.client.get(reverse("billboard:my_buds"))
buds = list(response.context["buds"])
self.assertIn(self.alice, buds)
self.assertIn(self.bob, buds)
def test_my_buds_does_not_list_others_buds(self):
other = User.objects.create(email="other@test.io")
carol = User.objects.create(email="carol@test.io", username="carol")
other.buds.add(carol)
response = self.client.get(reverse("billboard:my_buds"))
self.assertNotIn(carol, list(response.context["buds"]))
def test_my_buds_redirects_anon_to_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_buds"))
self.assertEqual(response.status_code, 302)
class AddBudViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
def test_add_registered_email_adds_to_buds(self):
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(alice, self.user.buds.all())
def test_add_returns_bud_payload_with_username(self):
User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
body = response.json()
self.assertIsNotNone(body["bud"])
self.assertEqual(body["bud"]["username"], "alice")
def test_add_unregistered_email_returns_null_bud(self):
"""Privacy: 200 with bud=null so the response shape doesn't leak
whether the address is on the system."""
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "ghost@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["bud"])
self.assertEqual(self.user.buds.count(), 0)
def test_add_own_email_is_silent_noop(self):
"""Adding yourself: no bud added, response carries bud=null."""
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "me@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["bud"])
self.assertNotIn(self.user, self.user.buds.all())
def test_add_existing_bud_is_idempotent(self):
alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buds.add(alice)
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
# Still only one bud entry — M2M dedup
self.assertEqual(self.user.buds.count(), 1)
# Response still carries the bud payload (so the JS can refresh
# an entry if a fast double-click bypassed the data-bud-id guard).
self.assertIsNotNone(response.json()["bud"])
def test_add_falls_back_to_email_when_no_username(self):
"""Bud payload returns email when bud.username is None — display
layer matches the navbar fallback (display_name filter)."""
User.objects.create(email="anon@test.io")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "anon@test.io"},
)
self.assertEqual(response.json()["bud"]["username"], "anon@test.io")
def test_get_returns_405(self):
response = self.client.get(reverse("billboard:add_bud"))
self.assertEqual(response.status_code, 405)

View File

@@ -13,7 +13,7 @@ from apps.lyric.models import User
def _seed_billboard_applets(): def _seed_billboard_applets():
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-buddies", "My Buddies", 4, 3), ("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
self.assertIn("applets", response.context) self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]] slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("my-scrolls", slugs) self.assertIn("my-scrolls", slugs)
self.assertIn("my-buddies", slugs) self.assertIn("my-buds", slugs)
self.assertIn("most-recent-scroll", slugs) self.assertIn("most-recent-scroll", slugs)
def test_passes_my_rooms_context(self): def test_passes_my_rooms_context(self):
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-buddies") contacts = Applet.objects.get(slug="my-buds")
ua = UserApplet.objects.get(user=self.user, applet=contacts) ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible) self.assertFalse(ua.visible)
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
"my-scrolls", "my-scrolls",
"my-buddies", "my-buds",
"most-recent-scroll", "most-recent-scroll",
]}, ]},
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1) self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self): def test_second_toggle_preserves_prior_hidden_state(self):
# First toggle: hide My Buddies only. # First toggle: hide My Buds only.
self.client.post( self.client.post(
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
]}, ]},
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
) )
# Second toggle: hide Most Recent Scroll additionally — My Buddies must stay hidden. # Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
self.client.post( self.client.post(
reverse("billboard:toggle_applets"), reverse("billboard:toggle_applets"),
{"applets": [ {"applets": [
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
HTTP_HX_REQUEST="true", HTTP_HX_REQUEST="true",
) )
from apps.applets.models import UserApplet from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-buddies") contacts = Applet.objects.get(slug="my-buds")
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll") most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
self.assertFalse( self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible UserApplet.objects.get(user=self.user, applet=contacts).visible

View File

@@ -18,6 +18,6 @@ urlpatterns = [
path("post/<uuid:post_id>/", views.view_post, name="view_post"), path("post/<uuid:post_id>/", views.view_post, name="view_post"),
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"), path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"), path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
path("my-buddies/", views.my_buddies, name="my_buddies"), path("my-buds/", views.my_buds, name="my_buds"),
path("buddies/add", views.add_buddy, name="add_buddy"), path("buds/add", views.add_bud, name="add_bud"),
] ]

View File

@@ -300,7 +300,7 @@ def my_posts(request, user_id):
return HttpResponseForbidden() return HttpResponseForbidden()
return render(request, "apps/billboard/my_posts.html", { return render(request, "apps/billboard/my_posts.html", {
"owner": owner, "owner": owner,
"page_class": "page-billboard", "page_class": "page-billposts",
}) })
@@ -373,44 +373,44 @@ def share_post(request, post_id):
return redirect(our_post) return redirect(our_post)
# ── My Buddies ──────────────────────────────────────────────────────────── # ── My Buds ───────────────────────────────────────────────────────────────
# User.buddies is an asymmetric self M2M (lyric/0004). `my_buddies` is the # User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
# manage-page; `add_buddy` is the JSON endpoint hit by the buddy panel slide- # is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
# out. Privacy: when an entered email isn't a registered User, we 200 with # slide-out. Privacy: when an entered email isn't a registered User, we
# {buddy: null} so the response shape doesn't leak membership. # 200 with {bud: null} so the response shape doesn't leak membership.
@login_required(login_url="/") @login_required(login_url="/")
def my_buddies(request): def my_buds(request):
return render(request, "apps/billboard/my_buddies.html", { return render(request, "apps/billboard/my_buds.html", {
"buddies": request.user.buddies.all(), "buds": request.user.buds.all(),
"page_class": "page-billbuddies", "page_class": "page-billbuds",
}) })
@login_required(login_url="/") @login_required(login_url="/")
def add_buddy(request): def add_bud(request):
if request.method != "POST": if request.method != "POST":
from django.http import HttpResponseNotAllowed from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"]) return HttpResponseNotAllowed(["POST"])
email = (request.POST.get("recipient") or "").strip() email = (request.POST.get("recipient") or "").strip()
buddy = None bud = None
try: try:
candidate = User.objects.get(email=email) candidate = User.objects.get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
candidate = None candidate = None
if candidate is not None and candidate != request.user: if candidate is not None and candidate != request.user:
if candidate not in request.user.buddies.all(): if candidate not in request.user.buds.all():
request.user.buddies.add(candidate) request.user.buds.add(candidate)
buddy = { bud = {
"id": str(candidate.id), "id": str(candidate.id),
"username": candidate.username or candidate.email, "username": candidate.username or candidate.email,
"email": candidate.email, "email": candidate.email,
} }
return JsonResponse({"buddy": buddy}) return JsonResponse({"bud": bud})
@login_required(login_url="/") @login_required(login_url="/")

View File

@@ -83,6 +83,6 @@ const Note = Brief;
// `const Brief = (...)` at script-tag scope is reachable as a bare name but // `const Brief = (...)` at script-tag scope is reachable as a bare name but
// is NOT auto-attached to window — explicit assignment so callers that gate // is NOT auto-attached to window — explicit assignment so callers that gate
// on `if (window.Brief)` (e.g. _buddy_panel.html's OK handler) succeed. // on `if (window.Brief)` (e.g. _bud_panel.html's OK handler) succeed.
window.Brief = Brief; window.Brief = Brief;
window.Note = Note; window.Note = Note;

View File

@@ -0,0 +1,32 @@
"""Rename User.buddies → User.buds.
Django's RenameField doesn't rename the implicit M2M through table
(`lyric_user_buddies` → `lyric_user_buds`), so we drop and re-add the
field. The buddies M2M was introduced one commit prior (0004) — no
production data to preserve. UI-vocabulary tightening (BILLBUDDIES
overflowed the page-header band; in-game term collapses to BILLBUDS).
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lyric", "0004_user_buddies"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="buddies",
),
migrations.AddField(
model_name="user",
name="buds",
field=models.ManyToManyField(
blank=True,
related_name="added_as_bud",
to="lyric.user",
),
),
]

View File

@@ -113,13 +113,13 @@ class User(AbstractBaseUser):
unlocked_decks = models.ManyToManyField( unlocked_decks = models.ManyToManyField(
"epic.DeckVariant", blank=True, related_name="unlocked_by", "epic.DeckVariant", blank=True, related_name="unlocked_by",
) )
# Asymmetric self M2M — `user.buddies.all()` = people I've explicitly # Asymmetric self M2M — `user.buds.all()` = people I've explicitly
# added (or implicitly via post-share / game-invite, which auto-adds # added (or implicitly via post-share / game-invite, which auto-adds
# the recipient to the inviter's buddies list). `user.added_as_buddy` # the recipient to the inviter's buds list). `user.added_as_bud` is
# = the inverse (people who have me in their buddies list); useful # the inverse (people who have me in their buds list); useful for
# for the future "buddy changed username" snapshot-accept flow. # the future "bud changed username" snapshot-accept flow.
buddies = models.ManyToManyField( buds = models.ManyToManyField(
"self", symmetrical=False, blank=True, related_name="added_as_buddy", "self", symmetrical=False, blank=True, related_name="added_as_bud",
) )
active_title = models.ForeignKey( active_title = models.ForeignKey(
"drama.Note", null=True, blank=True, "drama.Note", null=True, blank=True,

View File

@@ -55,17 +55,17 @@ class PostPage:
) )
def share_post_with(self, email): def share_post_with(self, email):
# Buddy-btn flow (post-Brief sprint): click bottom-left handshake, # Bud-btn flow (post-Brief sprint): click bottom-left handshake,
# type the email in the slide-out, click the .btn-confirm OK, wait # type the email in the slide-out, click the .btn-confirm OK, wait
# for the recipient chip. # for the recipient chip.
buddy_btn = self.test.browser.find_element(By.ID, "id_buddy_btn") bud_btn = self.test.browser.find_element(By.ID, "id_bud_btn")
buddy_btn.click() bud_btn.click()
recipient = self.test.wait_for( recipient = self.test.wait_for(
lambda: self.test.browser.find_element(By.ID, "id_recipient") lambda: self.test.browser.find_element(By.ID, "id_recipient")
) )
recipient.send_keys(email) recipient.send_keys(email)
ok = self.test.browser.find_element( ok = self.test.browser.find_element(
By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm" By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
) )
ok.click() ok.click()
self.test.wait_for( self.test.wait_for(

View File

@@ -59,21 +59,21 @@ class AdminPostInputReadonlyTest(FunctionalTest):
) )
class AdminPostHasNoBuddyBtnTest(FunctionalTest): class AdminPostHasNoBudBtnTest(FunctionalTest):
"""Admin-Post (note-unlock thread) suppresses #id_buddy_btn — friend """Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend
invites don't apply to system-authored threads. User-Post still invites don't apply to system-authored threads. User-Post still
renders the btn (regression coverage in test_buddy_btn.py).""" renders the btn (regression coverage in test_bud_btn.py)."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="nobuddy@test.io") self.gamer = User.objects.create(email="nobud@test.io")
Note.grant_if_new(self.gamer, "stargazer") Note.grant_if_new(self.gamer, "stargazer")
self.admin_post = Post.objects.get( self.admin_post = Post.objects.get(
owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK, owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK,
) )
self.create_pre_authenticated_session("nobuddy@test.io") self.create_pre_authenticated_session("nobud@test.io")
def test_buddy_btn_absent_on_admin_post(self): def test_bud_btn_absent_on_admin_post(self):
self.browser.get( self.browser.get(
self.live_server_url + f"/billboard/post/{self.admin_post.id}/" self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
) )
@@ -82,8 +82,8 @@ class AdminPostHasNoBuddyBtnTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_post_line_text") lambda: self.browser.find_element(By.ID, "id_post_line_text")
) )
self.assertFalse( self.assertFalse(
self.browser.find_elements(By.ID, "id_buddy_btn"), self.browser.find_elements(By.ID, "id_bud_btn"),
"Admin-Post must NOT render #id_buddy_btn", "Admin-Post must NOT render #id_bud_btn",
) )

View File

@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
super().setUp() super().setUp()
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-buddies", "My Buddies", 4, 3), ("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
class BillboardAppletsTest(FunctionalTest): class BillboardAppletsTest(FunctionalTest):
""" """
FT: billboard page renders three applets in the grid — My Scrolls, FT: billboard page renders three applets in the grid — My Scrolls,
My Buddies, and Most Recent Scroll — with a functioning gear menu. My Buds, and Most Recent Scroll — with a functioning gear menu.
""" """
def setUp(self): def setUp(self):
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-buddies", "My Buddies", 4, 3), ("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
) )
self.browser.find_element(By.ID, "id_applet_my_buddies") self.browser.find_element(By.ID, "id_applet_my_buds")
self.browser.find_element(By.ID, "id_applet_most_recent_scroll") self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
def test_billboard_my_scrolls_lists_rooms(self): def test_billboard_my_scrolls_lists_rooms(self):
@@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu") lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
) )
contacts_cb = menu.find_element( contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-buddies']" By.CSS_SELECTOR, "input[value='my-buds']"
) )
self.browser.execute_script("arguments[0].click()", contacts_cb) self.browser.execute_script("arguments[0].click()", contacts_cb)
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
@@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest):
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2) # Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buddies"), self.browser.find_elements(By.ID, "id_applet_my_buds"),
[], [],
) )
) )
@@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest):
) )
# The freshly-rendered menu must reflect DB state (Contacts unchecked) # The freshly-rendered menu must reflect DB state (Contacts unchecked)
contacts_cb = menu.find_element( contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-buddies']" By.CSS_SELECTOR, "input[value='my-buds']"
) )
self.assertFalse(contacts_cb.is_selected()) self.assertFalse(contacts_cb.is_selected())
most_recent_scroll_cb = menu.find_element( most_recent_scroll_cb = menu.find_element(
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
) )
) )
self.assertEqual( self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buddies"), self.browser.find_elements(By.ID, "id_applet_my_buds"),
[], [],
) )
@@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
) )
self.assertEqual( self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buddies"), self.browser.find_elements(By.ID, "id_applet_my_buds"),
[], [],
) )
self.assertEqual( self.assertEqual(

View File

@@ -1,9 +1,9 @@
"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button. """FT spec for the Bud btn sprint — post.html bottom-left handshake button.
Written red BEFORE implementation as a TDD handoff so the post-compaction Written red BEFORE implementation as a TDD handoff so the post-compaction
agent (or future Disco) can land the feature without losing intent. Run: agent (or future Disco) can land the feature without losing intent. Run:
python src/manage.py test functional_tests.test_buddy_btn python src/manage.py test functional_tests.test_bud_btn
All tests should be RED initially. Implementation lands when they go green. All tests should be RED initially. Implementation lands when they go green.
@@ -11,22 +11,22 @@ All tests should be RED initially. Implementation lands when they go green.
SPEC SUMMARY SPEC SUMMARY
A new #id_buddy_btn (<i class="fa-solid fa-handshake">) sits bottom-left A new #id_bud_btn (<i class="fa-solid fa-handshake">) sits bottom-left
of the viewport mirror of #id_kit_btn (bottom-right). Shares the same of the viewport mirror of #id_kit_btn (bottom-right). Shares the same
fixed/circular/secUser-bordered look + .active-state styling. fixed/circular/secUser-bordered look + .active-state styling.
Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html, Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html,
included only by post.html (NOT the global base.html buddy is post-only). included only by post.html (NOT the global base.html bud is post-only).
Replaces the inline share form on post.html: typing the recipient now Replaces the inline share form on post.html: typing the recipient now
happens in a slide-out under the buddy btn. happens in a slide-out under the bud btn.
Click #id_buddy_btn → recipient field grows L→R under it, spanning Click #id_bud_btn → recipient field grows L→R under it, spanning
`100vw - 3rem` (1.5rem padding each side). The field is vertically `100vw - 3rem` (1.5rem padding each side). The field is vertically
centred on the buddy btn's centre, w. healthy left padding so the typed centred on the bud btn's centre, w. healthy left padding so the typed
text + placeholder don't overlap the btn glyph. text + placeholder don't overlap the btn glyph.
An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
field (replaces the legacy big SHARE .btn-primary). field (replaces the legacy big SHARE .btn-primary).
While the recipient field is open: html.buddy-open is set; #id_kit_btn While the recipient field is open: html.bud-open is set; #id_kit_btn
quickly eases to opacity 0. Symmetric: when html.kit-open is set, quickly eases to opacity 0. Symmetric: when html.kit-open is set,
#id_buddy_btn eases to opacity 0. #id_bud_btn eases to opacity 0.
Click OK POST share-post async (existing C3.b endpoint), clears the Click OK POST share-post async (existing C3.b endpoint), clears the
field, closes the slide-out, slide-down Brief banner appears. field, closes the slide-out, slide-down Brief banner appears.
Click outside the field/btn closes the slide-out, clears the field Click outside the field/btn closes the slide-out, clears the field
@@ -40,29 +40,29 @@ SPEC SUMMARY
IMPLEMENTATION CHECKLIST (post-compaction) IMPLEMENTATION CHECKLIST (post-compaction)
1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn 1. Create templates/apps/billboard/_partials/_bud_panel.html w. the btn
+ slide-out form + inline JS. + slide-out form + inline JS.
2. Edit templates/apps/billboard/post.html: 2. Edit templates/apps/billboard/post.html:
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn - Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
block (the share JS moves into _buddy_panel.html). block (the share JS moves into _bud_panel.html).
- Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`. - Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`.
3. Edit billboard.views.view_post (or my_posts) context: 3. Edit billboard.views.view_post (or my_posts) context:
- "page_class": "page-billboard" (or new page-post) so the body class - "page_class": "page-billboard" (or new page-post) so the body class
picks up the aperture SCSS. picks up the aperture SCSS.
4. SCSS add to _game-kit.scss neighbour or new _buddy.scss: 4. SCSS add to _game-kit.scss neighbour or new _bud.scss:
- #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn - #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn
(3rem circle, secUser border, .active state, etc.) (3rem circle, secUser border, .active state, etc.)
- #id_buddy_panel (the slide-out wrapper): position fixed, - #id_bud_panel (the slide-out wrapper): position fixed,
left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
kit btn visible but mutual-exclusion makes that moot), bottom- kit btn visible but mutual-exclusion makes that moot), bottom-
aligned w. the btn centre, transition transform/width LR. aligned w. the btn centre, transition transform/width LR.
- html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; } - html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
- html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; } - html.kit-open #id_bud_btn { opacity: 0; transition: opacity 0.15s; }
- The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on - The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
the trailing edge of the slide-out. the trailing edge of the slide-out.
5. JS in _buddy_panel.html: 5. JS in _bud_panel.html:
- Mirror game-kit.js click/escape/click-outside pattern. - Mirror game-kit.js click/escape/click-outside pattern.
- Toggle html.buddy-open + #id_buddy_btn.active. - Toggle html.bud-open + #id_bud_btn.active.
- On submit/OK: fetch POST share-post w. Accept:application/json, - On submit/OK: fetch POST share-post w. Accept:application/json,
reuse the C3.b response handling (line append, banner via reuse the C3.b response handling (line append, banner via
Brief.showBanner, recipient_display chip append). Brief.showBanner, recipient_display chip append).
@@ -70,7 +70,7 @@ IMPLEMENTATION CHECKLIST (post-compaction)
6. post.html + my_posts.html: add the body class hook so the aperture 6. post.html + my_posts.html: add the body class hook so the aperture
SCSS engages (probably page-billboard already, just need to confirm). SCSS engages (probably page-billboard already, just need to confirm).
7. Update functional_tests.post_page.PostPage.share_post_with() to 7. Update functional_tests.post_page.PostPage.share_post_with() to
drive the buddy-btn flow (click btn type click OK wait for chip). drive the bud-btn flow (click btn type click OK wait for chip).
8. Re-run test_sharing.SharingTest should still pass once the page- 8. Re-run test_sharing.SharingTest should still pass once the page-
object mirrors the new flow. object mirrors the new flow.
@@ -82,7 +82,7 @@ KNOWN AT TIME OF WRITING
- The C3.b share-post async endpoint accepts Accept: application/json, - The C3.b share-post async endpoint accepts Accept: application/json,
returns {brief, line_text, recipient_display}; intercepted by the returns {brief, line_text, recipient_display}; intercepted by the
inline JS in post.html's existing #id_share_form. That JS moves into inline JS in post.html's existing #id_share_form. That JS moves into
_buddy_panel.html. _bud_panel.html.
- body.page-billboard class is set by billboard:billboard view; post.html - body.page-billboard class is set by billboard:billboard view; post.html
needs it (or its own class) added in billboard.views.view_post. needs it (or its own class) added in billboard.views.view_post.
""" """
@@ -104,8 +104,8 @@ def _seed_a_post(user):
return p return p
class BuddyBtnPresenceTest(FunctionalTest): class BudBtnPresenceTest(FunctionalTest):
"""The buddy btn is post-only — present on post.html, absent elsewhere.""" """The bud btn is post-only — present on post.html, absent elsewhere."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -113,46 +113,46 @@ class BuddyBtnPresenceTest(FunctionalTest):
slug="my-posts", slug="my-posts",
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
) )
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
# ── B1 ────────────────────────────────────────────────────────────────── # ── B1 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_renders_on_post_html(self): def test_bud_btn_renders_on_post_html(self):
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake") icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
self.assertIsNotNone(icon) self.assertIsNotNone(icon)
# ── B2 ────────────────────────────────────────────────────────────────── # ── B2 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_absent_on_dashboard(self): def test_bud_btn_absent_on_dashboard(self):
self.browser.get(self.live_server_url + "/") self.browser.get(self.live_server_url + "/")
# Allow page to settle # Allow page to settle
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
# ── B3 ────────────────────────────────────────────────────────────────── # ── B3 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_absent_on_billboard_index(self): def test_bud_btn_absent_on_billboard_index(self):
self.browser.get(self.live_server_url + "/billboard/") self.browser.get(self.live_server_url + "/billboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
class BuddyBtnPositionTest(FunctionalTest): class BudBtnPositionTest(FunctionalTest):
"""The btn sits bottom-left, mirror of #id_kit_btn's bottom-right.""" """The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_buddy_btn_is_fixed_bottom_left(self): def test_bud_btn_is_fixed_bottom_left(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
cs = self.browser.execute_script( cs = self.browser.execute_script(
"var s = getComputedStyle(arguments[0]); " "var s = getComputedStyle(arguments[0]); "
"return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};", "return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
@@ -164,9 +164,9 @@ class BuddyBtnPositionTest(FunctionalTest):
self.assertNotEqual(cs["bottom"], "auto") self.assertNotEqual(cs["bottom"], "auto")
self.assertNotEqual(cs["left"], "auto") self.assertNotEqual(cs["left"], "auto")
def test_buddy_btn_size_matches_kit_btn(self): def test_bud_btn_size_matches_kit_btn(self):
"""Same circular-3rem look — visually a mirror pair.""" """Same circular-3rem look — visually a mirror pair."""
btn = self.browser.find_element(By.ID, "id_buddy_btn") btn = self.browser.find_element(By.ID, "id_bud_btn")
kit = self.browser.find_element(By.ID, "id_kit_btn") kit = self.browser.find_element(By.ID, "id_kit_btn")
b_box = btn.size b_box = btn.size
k_box = kit.size k_box = kit.size
@@ -174,47 +174,47 @@ class BuddyBtnPositionTest(FunctionalTest):
self.assertEqual(b_box["height"], k_box["height"]) self.assertEqual(b_box["height"], k_box["height"])
class BuddyBtnSlideOutTest(FunctionalTest): class BudBtnSlideOutTest(FunctionalTest):
"""Click the buddy btn → recipient field + OK btn slide out under it.""" """Click the bud btn → recipient field + OK btn slide out under it."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_recipient_field_hidden_until_click(self): def test_recipient_field_hidden_until_click(self):
"""Pre-click: the field is in DOM but visually closed (e.g. width 0 """Pre-click: the field is in DOM but visually closed (e.g. width 0
or transform scaleX(0)) assertion checks it doesn't take its full or transform scaleX(0)) assertion checks it doesn't take its full
viewport-spanning width yet.""" viewport-spanning width yet."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
# The field+OK panel is rendered (so JS can transition it) but should # The field+OK panel is rendered (so JS can transition it) but should
# be in a closed state — assert the panel container exists and the # be in a closed state — assert the panel container exists and the
# input is not displayed at full width (a CSS-driven slide-out). # input is not displayed at full width (a CSS-driven slide-out).
panel = self.browser.find_element(By.ID, "id_buddy_panel") panel = self.browser.find_element(By.ID, "id_bud_panel")
# Before click, panel visible-width should be < viewport / 2 (closed) # Before click, panel visible-width should be < viewport / 2 (closed)
viewport_w = self.browser.execute_script("return window.innerWidth;") viewport_w = self.browser.execute_script("return window.innerWidth;")
self.assertLess(panel.size["width"], viewport_w / 2) self.assertLess(panel.size["width"], viewport_w / 2)
def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self): def test_click_bud_btn_reveals_recipient_field_and_ok_btn(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
self.assertTrue(recipient.is_displayed()) self.assertTrue(recipient.is_displayed())
# OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE # OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
self.assertEqual(ok.text.strip().upper(), "OK") self.assertEqual(ok.text.strip().upper(), "OK")
# Buddy btn picks up .active when open (mirror kit-btn pattern) # Bud btn picks up .active when open (mirror kit-btn pattern)
self.assertIn("active", btn.get_attribute("class")) self.assertIn("active", btn.get_attribute("class"))
def test_panel_spans_almost_full_viewport_when_open(self): def test_panel_spans_almost_full_viewport_when_open(self):
"""When open, the panel spans 100vw - 3rem (1.5rem each side).""" """When open, the panel spans 100vw - 3rem (1.5rem each side)."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
panel = self.browser.find_element(By.ID, "id_buddy_panel") panel = self.browser.find_element(By.ID, "id_bud_panel")
# Wait for the slide-out transition to settle # Wait for the slide-out transition to settle
self.wait_for(lambda: self.assertGreater( self.wait_for(lambda: self.assertGreater(
panel.size["width"], panel.size["width"],
@@ -222,32 +222,32 @@ class BuddyBtnSlideOutTest(FunctionalTest):
)) ))
def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self): def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
pad = self.browser.execute_script( pad = self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);", "return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
recipient, recipient,
) )
# At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't # At least 2.5rem (40px-ish) so the bud glyph (3rem circle) doesn't
# overlap the placeholder/typed text. # overlap the placeholder/typed text.
self.assertGreaterEqual(pad, 32) self.assertGreaterEqual(pad, 32)
class BuddyKitMutualExclusionTest(FunctionalTest): class BudKitMutualExclusionTest(FunctionalTest):
"""When kit btn is active, buddy btn fades to 0 — and vice-versa.""" """When kit btn is active, bud btn fades to 0 — and vice-versa."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_buddy_active_fades_kit_btn(self): def test_bud_active_fades_kit_btn(self):
buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
kit = self.browser.find_element(By.ID, "id_kit_btn") kit = self.browser.find_element(By.ID, "id_kit_btn")
buddy.click() bud.click()
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.execute_script( self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).opacity);", "return parseFloat(getComputedStyle(arguments[0]).opacity);",
@@ -256,32 +256,32 @@ class BuddyKitMutualExclusionTest(FunctionalTest):
0.0, 0.0,
)) ))
def test_kit_active_fades_buddy_btn(self): def test_kit_active_fades_bud_btn(self):
kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
buddy = self.browser.find_element(By.ID, "id_buddy_btn") bud = self.browser.find_element(By.ID, "id_bud_btn")
kit.click() kit.click()
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.execute_script( self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).opacity);", "return parseFloat(getComputedStyle(arguments[0]).opacity);",
buddy, bud,
), ),
0.0, 0.0,
)) ))
class BuddyBtnDismissTest(FunctionalTest): class BudBtnDismissTest(FunctionalTest):
"""Click outside / Escape closes the panel; field is cleared; reopening """Click outside / Escape closes the panel; field is cleared; reopening
shows the placeholder, not the previously-typed value.""" shows the placeholder, not the previously-typed value."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def _open_and_type(self, text): def _open_and_type(self, text):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys(text) recipient.send_keys(text)
@@ -310,24 +310,24 @@ class BuddyBtnDismissTest(FunctionalTest):
self.assertEqual(recipient.get_attribute("value"), "") self.assertEqual(recipient.get_attribute("value"), "")
class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest): class BudBtnOkSubmitsAsyncShareTest(FunctionalTest):
"""OK → POST share-post (Accept:application/json) → Brief banner + """OK → POST share-post (Accept:application/json) → Brief banner +
recipient chip appended; field clears; panel closes.""" recipient chip appended; field clears; panel closes."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.sharer = User.objects.create(email="buddy@test.io") self.sharer = User.objects.create(email="bud@test.io")
self.recipient = User.objects.create(email="alice@test.io") self.recipient = User.objects.create(email="alice@test.io")
self.post = _seed_a_post(self.sharer) self.post = _seed_a_post(self.sharer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_ok_creates_brief_appends_line_and_chip(self): def test_ok_creates_brief_appends_line_and_chip(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient_input.send_keys("alice@test.io") recipient_input.send_keys("alice@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
ok.click() ok.click()
# 1. Brief is created server-side # 1. Brief is created server-side
@@ -358,9 +358,9 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
slug="my-posts", slug="my-posts",
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
) )
self.gamer = User.objects.create(email="buddy@test.io") self.gamer = User.objects.create(email="bud@test.io")
self.post = _seed_a_post(self.gamer) self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io") self.create_pre_authenticated_session("bud@test.io")
def test_post_html_body_carries_billboard_or_post_page_class(self): def test_post_html_body_carries_billboard_or_post_page_class(self):
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

View File

@@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest):
# Billboard applets — page renders blank without these # Billboard applets — page renders blank without these
for slug, name, cols, rows in [ for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-buddies", "My Buddies", 4, 3), ("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(

View File

@@ -1,6 +1,6 @@
"""FT for the My Buddies page — buddy btn + slide-out add flow. """FT for the My Buds page — bud btn + slide-out add flow.
Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2 Phase 1 of the buds sprint: explicit add via my_buds.html. Phase 2
will layer autocomplete (sky-place-style top-3 username suggestions) and will layer autocomplete (sky-place-style top-3 username suggestions) and
implicit auto-add on post-share / gate-invite. implicit auto-add on post-share / gate-invite.
""" """
@@ -12,64 +12,64 @@ from apps.lyric.models import User
from .base import FunctionalTest from .base import FunctionalTest
class MyBuddiesPageTest(FunctionalTest): class MyBudsPageTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me") self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice") self.alice = User.objects.create(email="alice@test.io", username="alice")
self.create_pre_authenticated_session("me@test.io") self.create_pre_authenticated_session("me@test.io")
def test_renders_existing_buddies(self): def test_renders_existing_buds(self):
"""Pre-existing buddies show up as entries on first render.""" """Pre-existing buds show up as entries on first render."""
self.gamer.buddies.add(self.alice) self.gamer.buds.add(self.alice)
self.browser.get(self.live_server_url + "/billboard/my-buddies/") self.browser.get(self.live_server_url + "/billboard/my-buds/")
entry = self.wait_for( entry = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name") lambda: self.browser.find_element(By.CSS_SELECTOR, ".bud-entry .bud-name")
) )
self.assertEqual(entry.text, "alice") self.assertEqual(entry.text, "alice")
def test_empty_state_when_no_buddies(self): def test_empty_state_when_no_buds(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/") self.browser.get(self.live_server_url + "/billboard/my-buds/")
empty = self.wait_for( empty = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty") lambda: self.browser.find_element(By.CSS_SELECTOR, ".applet-list-entry--empty")
) )
self.assertIn("No buddies yet", empty.text) self.assertIn("No buds yet", empty.text)
def test_add_buddy_via_buddy_btn_appends_entry(self): def test_add_bud_via_bud_btn_appends_entry(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/") self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io") recipient.send_keys("alice@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
ok.click() ok.click()
# New entry appears w. alice's username (not the bare email) # New entry appears w. alice's username (not the bare email)
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.find_element( self.browser.find_element(
By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name" By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name"
).text, ).text,
"alice", "alice",
)) ))
# Server-side persisted # Server-side persisted
self.wait_for(lambda: self.assertIn( self.wait_for(lambda: self.assertIn(
self.alice, list(self.gamer.buddies.all()) self.alice, list(self.gamer.buds.all())
)) ))
def test_add_unregistered_email_is_silent_noop(self): def test_add_unregistered_email_is_silent_noop(self):
self.browser.get(self.live_server_url + "/billboard/my-buddies/") self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click() btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("ghost@test.io") recipient.send_keys("ghost@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
ok.click() ok.click()
# Wait for the panel close (a positive signal the request landed) # Wait for the panel close (a positive signal the request landed)
self.wait_for(lambda: self.assertNotIn( self.wait_for(lambda: self.assertNotIn(
"active", btn.get_attribute("class") "active", btn.get_attribute("class")
)) ))
# No entries beyond the empty-state row # No bud entries (the empty-state row has its own --empty class)
entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)") entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
self.assertEqual(len(entries), 0) self.assertEqual(len(entries), 0)

View File

@@ -36,14 +36,16 @@
html:has(body.page-billboard), html:has(body.page-billboard),
html:has(body.page-billscroll), html:has(body.page-billscroll),
html:has(body.page-billpost), html:has(body.page-billpost),
html:has(body.page-billbuddies) { html:has(body.page-billbuds),
html:has(body.page-billposts) {
overflow: hidden; overflow: hidden;
} }
body.page-billboard, body.page-billboard,
body.page-billscroll, body.page-billscroll,
body.page-billpost, body.page-billpost,
body.page-billbuddies { body.page-billbuds,
body.page-billposts {
overflow: hidden; overflow: hidden;
.container { .container {
@@ -217,54 +219,62 @@ body.page-billbuddies {
} }
} }
// ── My Buddies page (aperture list + add-buddy panel) ──────────────────── // ── Applet-list page (Billbuds, Billposts) ───────────────────────────────
// Mirrors .post-page's flex-column / overflow / bottom-anchor pattern; // Shared shell for pages built around _applet-list-shell.html — vertical
// the add-buddy panel is included from the same _buddy*.scss styling. // title rotated on the left of an .applet-scroll card + scrollable <ul>
// aperture. `--single` hosts one section (My Buds); `--two-up` stacks
// two sections in portrait, places them side-by-side in landscape (My
// Posts: own + shared).
.buddies-page { .applet-list-page {
@extend %billboard-page-base; @extend %billboard-page-base;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.75rem; padding: 0.75rem;
gap: 0.5rem; gap: 0.75rem;
.buddies-header { .applet-scroll {
flex-shrink: 0; @extend %applet-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.buddies-title { .applet-list {
margin: 0 0 0.25rem; list-style: none;
font-weight: bold; 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: inherit;
text-decoration: none;
font-weight: bold;
&:hover { opacity: 0.85; }
}
}
.applet-list-buffer {
flex-shrink: 0;
height: 0.5rem;
} }
} }
#id_buddies_list { // Side-by-side in landscape; stacked in portrait (default).
list-style: none; &--two-up {
margin: 0; @media (orientation: landscape) {
padding: 0 0.75rem 0 0; flex-direction: row;
flex: 1; .applet-scroll { flex: 1; }
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
.buddy-entry {
padding: 0.4rem 0;
.buddy-name {
font-weight: bold;
opacity: 0.85;
}
&--empty {
opacity: 0.6;
font-style: italic;
}
}
.buddy-entry-buffer {
flex-shrink: 0;
height: 0.25rem;
} }
} }
} }
@@ -276,13 +286,13 @@ body.page-billbuddies {
#id_billboard_applets_container { #id_billboard_applets_container {
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; } #id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
#id_applet_my_buddies { grid-column: 1 / span 4; grid-row: 4 / span 3; } #id_applet_my_buds { grid-column: 1 / span 4; grid-row: 4 / span 3; }
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; } #id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; } #id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
@container (max-width: 550px) { @container (max-width: 550px) {
#id_applet_my_scrolls, #id_applet_my_scrolls,
#id_applet_my_buddies, #id_applet_my_buds,
#id_applet_notes, #id_applet_notes,
#id_applet_most_recent_scroll { #id_applet_most_recent_scroll {
grid-column: 1 / span 12; grid-column: 1 / span 12;

View File

@@ -1,13 +1,13 @@
// Buddy btn (bottom-left mirror of #id_kit_btn) // Bud btn (bottom-left mirror of #id_kit_btn)
// //
// Lives on post.html only slide-out recipient field for the share-post // Lives on post.html only slide-out recipient field for the share-post
// async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is // async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is
// active (.active class on btn + html.{kit|buddy}-open class on root), the // active (.active class on btn + html.{kit|bud}-open class on root), the
// other quickly fades to opacity 0. // other quickly fades to opacity 0.
// //
// Spec: functional_tests/test_buddy_btn.py. // Spec: functional_tests/test_bud_btn.py.
#id_buddy_btn { #id_bud_btn {
position: fixed; position: fixed;
bottom: 0.5rem; bottom: 0.5rem;
left: 0.5rem; left: 0.5rem;
@@ -43,12 +43,12 @@
} }
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem. // Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
#id_buddy_panel { #id_bud_panel {
position: fixed; position: fixed;
bottom: 0.5rem; // align bottom edge w. buddy btn bottom: 0.5rem; // align bottom edge w. bud btn
left: 1.5rem; left: 1.5rem;
right: 1.5rem; right: 1.5rem;
height: 3rem; // match buddy btn height for vertical-centre alignment height: 3rem; // match bud btn height for vertical-centre alignment
z-index: 317; z-index: 317;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -56,7 +56,7 @@
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
// Closed state collapse leftward into the buddy btn // Closed state collapse leftward into the bud btn
transform-origin: left center; transform-origin: left center;
transform: scaleX(0); transform: scaleX(0);
transition: transform 0.2s ease-out, opacity 0.15s ease; transition: transform 0.2s ease-out, opacity 0.15s ease;
@@ -76,7 +76,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
// Generous left padding so the buddy btn glyph (3rem circle pinned // Generous left padding so the bud btn glyph (3rem circle pinned
// at left:1.5rem) doesn't visually overlap the placeholder/typed text. // at left:1.5rem) doesn't visually overlap the placeholder/typed text.
padding: 0 1rem 0 3.5rem; padding: 0 1rem 0 3.5rem;
background-color: rgba(var(--priUser), 1); background-color: rgba(var(--priUser), 1);
@@ -97,9 +97,9 @@
} }
} }
// html.buddy-open: slide the panel out, fade the kit btn away. // html.bud-open: slide the panel out, fade the kit btn away.
html.buddy-open { html.bud-open {
#id_buddy_panel { #id_bud_panel {
transform: scaleX(1); transform: scaleX(1);
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@@ -111,10 +111,10 @@ html.buddy-open {
} }
} }
// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open` // Kit dialog open: hide the bud btn. We don't add an `html.kit-open`
// class (game-kit.js uses [open] on the dialog + .active on the btn), so // class (game-kit.js uses [open] on the dialog + .active on the btn), so
// the mutual-exclusion is driven by `:has()` against the open dialog. // the mutual-exclusion is driven by `:has()` against the open dialog.
html:has(#id_kit_bag_dialog[open]) #id_buddy_btn { html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }

View File

@@ -13,7 +13,7 @@
@import 'note'; @import 'note';
@import 'tooltips'; @import 'tooltips';
@import 'game-kit'; @import 'game-kit';
@import 'buddy'; @import 'bud';
@import 'wallet-tokens'; @import 'wallet-tokens';

View File

@@ -0,0 +1,23 @@
{# Shared applet-scroll-style list section — vertical-title <h2> on the #}
{# left + scrollable <ul> 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"). #}
{# #}
{# Parameters: #}
{# shell_title — vertical-rotated heading text (string) #}
{# shell_items — iterable rendered into the list #}
{# shell_item_template — partial rendering each <li>; receives `item` #}
{# shell_list_id — optional `id=` for the <ul> (e.g. "id_buds_list" #}
{# so buddy-panel JS can target it) #}
{# shell_empty — text for the {% empty %} fallback row #}
<section class="applet-scroll">
<h2>{{ shell_title }}</h2>
<ul {% if shell_list_id %}id="{{ shell_list_id }}"{% endif %} class="applet-list">
{% for item in shell_items %}
{% include shell_item_template %}
{% empty %}
<li class="applet-list-entry applet-list-entry--empty">{{ shell_empty|default:"Nothing here yet." }}</li>
{% endfor %}
<li class="applet-list-buffer" aria-hidden="true"></li>
</ul>
</section>

View File

@@ -1,7 +1,7 @@
<section <section
id="id_applet_my_buddies" id="id_applet_my_buds"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2><a href="{% url 'billboard:my_buddies' %}" class="my-buddies-main">My Buddies</a></h2> <h2><a href="{% url 'billboard:my_buds' %}" class="my-buds-main">My Buds</a></h2>
{% include "core/_partials/_forthcoming.html" %} {% include "core/_partials/_forthcoming.html" %}
</section> </section>

View File

@@ -1,32 +1,32 @@
{% load static %} {% load static %}
{# ─────────────────────────────────────────────────────────────────────── #} {# ─────────────────────────────────────────────────────────────────────── #}
{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #} {# _bud_add_panel.html — bottom-left handshake btn + slide-out add- #}
{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #} {# bud field. Mirrors _bud_panel.html (post-share) but POSTs to #}
{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #} {# add_bud and appends to #id_buds_list instead of #id_post_table. #}
{# Included by my_buddies.html only. #} {# Included by my_buds.html only. #}
{# ─────────────────────────────────────────────────────────────────────── #} {# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_buddy_btn" type="button" aria-label="Add a buddy"> <button id="id_bud_btn" type="button" aria-label="Add a bud">
<i class="fa-solid fa-handshake"></i> <i class="fa-solid fa-handshake"></i>
</button> </button>
<div id="id_buddy_panel" data-add-url="{% url 'billboard:add_buddy' %}"> <div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
<input id="id_recipient" <input id="id_recipient"
name="recipient" name="recipient"
type="email" type="email"
placeholder="friend@example.com" placeholder="friend@example.com"
autocomplete="off"> autocomplete="off">
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button> <button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div> </div>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
var btn = document.getElementById('id_buddy_btn'); var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_buddy_panel'); var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient'); var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_buddy_ok'); var ok = document.getElementById('id_bud_ok');
var html = document.documentElement; var html = document.documentElement;
if (!btn || !panel || !input || !ok) return; if (!btn || !panel || !input || !ok) return;
@@ -36,20 +36,20 @@
} }
function _open() { function _open() {
html.classList.add('buddy-open'); html.classList.add('bud-open');
btn.classList.add('active'); btn.classList.add('active');
setTimeout(function () { input.focus(); }, 60); setTimeout(function () { input.focus(); }, 60);
} }
function _close(opts) { function _close(opts) {
opts = opts || {}; opts = opts || {};
html.classList.remove('buddy-open'); html.classList.remove('bud-open');
btn.classList.remove('active'); btn.classList.remove('active');
if (opts.clear !== false) input.value = ''; if (opts.clear !== false) input.value = '';
} }
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
if (html.classList.contains('buddy-open')) { if (html.classList.contains('bud-open')) {
_close(); _close();
} else { } else {
_open(); _open();
@@ -57,34 +57,34 @@
}); });
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close(); if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
}); });
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!html.classList.contains('buddy-open')) return; if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return; if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return; if (e.target === btn || btn.contains(e.target)) return;
_close(); _close();
}); });
function _appendBuddyEntry(buddy) { function _appendBudEntry(bud) {
var list = document.getElementById('id_buddies_list'); var list = document.getElementById('id_buds_list');
if (!list || !buddy) return; if (!list || !bud) return;
// Skip if already in DOM (server-side dedup ensures M2M idempotence; // Skip if already in DOM (server-side dedup ensures M2M idempotence;
// this guards a fast double-click that races the post-add refresh). // this guards a fast double-click that races the post-add refresh).
if (list.querySelector('[data-buddy-id="' + buddy.id + '"]')) return; if (list.querySelector('[data-bud-id="' + bud.id + '"]')) return;
// Drop the empty-state row if present // Drop the empty-state row if present
var empty = list.querySelector('.buddy-entry--empty'); var empty = list.querySelector('.bud-entry--empty');
if (empty) empty.remove(); if (empty) empty.remove();
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'buddy-entry'; li.className = 'bud-entry';
li.dataset.buddyId = buddy.id; li.dataset.budId = bud.id;
var name = document.createElement('span'); var name = document.createElement('span');
name.className = 'buddy-name'; name.className = 'bud-name';
name.textContent = buddy.username; name.textContent = bud.username;
li.appendChild(name); li.appendChild(name);
var buffer = list.querySelector('.buddy-entry-buffer'); var buffer = list.querySelector('.bud-entry-buffer');
if (buffer) list.insertBefore(li, buffer); if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li); else list.appendChild(li);
} }
@@ -107,12 +107,12 @@
}) })
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) { .then(function (data) {
if (data.buddy) _appendBuddyEntry(data.buddy); if (data.bud) _appendBudEntry(data.bud);
_close({ clear: true }); _close({ clear: true });
}) })
.catch(function () { .catch(function () {
// Privacy-safe response shape — even an unregistered email is // Privacy-safe response shape — even an unregistered email is
// a 200 w. {buddy: null}. Network/5xx land here; just close. // a 200 w. {bud: null}. Network/5xx land here; just close.
}); });
}); });

View File

@@ -1,20 +1,20 @@
{% load static %} {% load static %}
{% load lyric_extras %} {% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #} {# ─────────────────────────────────────────────────────────────────────── #}
{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #} {# _bud_panel.html — bottom-left handshake btn + slide-out recipient #}
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #} {# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
{# right). Included by post.html only. #} {# right). Included by post.html only. #}
{# #} {# #}
{# Spec lives in functional_tests/test_buddy_btn.py — write it red-first. #} {# Spec lives in functional_tests/test_bud_btn.py — write it red-first. #}
{# Run: #} {# Run: #}
{# python src/manage.py test functional_tests.test_buddy_btn #} {# python src/manage.py test functional_tests.test_bud_btn #}
{# ─────────────────────────────────────────────────────────────────────── #} {# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_buddy_btn" type="button" aria-label="Share with a buddy"> <button id="id_bud_btn" type="button" aria-label="Share with a bud">
<i class="fa-solid fa-handshake"></i> <i class="fa-solid fa-handshake"></i>
</button> </button>
<div id="id_buddy_panel" <div id="id_bud_panel"
data-share-url="{% url 'billboard:share_post' post.id %}" data-share-url="{% url 'billboard:share_post' post.id %}"
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}"> data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<input id="id_recipient" <input id="id_recipient"
@@ -22,17 +22,17 @@
type="email" type="email"
placeholder="friend@example.com" placeholder="friend@example.com"
autocomplete="off"> autocomplete="off">
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button> <button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div> </div>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
var btn = document.getElementById('id_buddy_btn'); var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_buddy_panel'); var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient'); var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_buddy_ok'); var ok = document.getElementById('id_bud_ok');
var html = document.documentElement; var html = document.documentElement;
if (!btn || !panel || !input || !ok) return; if (!btn || !panel || !input || !ok) return;
@@ -42,7 +42,7 @@
} }
function _open() { function _open() {
html.classList.add('buddy-open'); html.classList.add('bud-open');
btn.classList.add('active'); btn.classList.add('active');
// small delay before focus so the slide-out animation can play // small delay before focus so the slide-out animation can play
setTimeout(function () { input.focus(); }, 60); setTimeout(function () { input.focus(); }, 60);
@@ -50,13 +50,13 @@
function _close(opts) { function _close(opts) {
opts = opts || {}; opts = opts || {};
html.classList.remove('buddy-open'); html.classList.remove('bud-open');
btn.classList.remove('active'); btn.classList.remove('active');
if (opts.clear !== false) input.value = ''; if (opts.clear !== false) input.value = '';
} }
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
if (html.classList.contains('buddy-open')) { if (html.classList.contains('bud-open')) {
_close(); _close();
} else { } else {
_open(); _open();
@@ -65,12 +65,12 @@
// Escape closes the panel, clears the field // Escape closes the panel, clears the field
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close(); if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
}); });
// Click-outside dismiss — same pattern as game-kit.js // Click-outside dismiss — same pattern as game-kit.js
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!html.classList.contains('buddy-open')) return; if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return; if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return; if (e.target === btn || btn.contains(e.target)) return;
_close(); _close();
@@ -107,7 +107,7 @@
else list.appendChild(li); else list.appendChild(li);
} }
// The shared-with header lives outside #id_buddy_panel — it's two <p> // The shared-with header lives outside #id_bud_panel — it's two <p>
// siblings under .post-header. State transitions: // siblings under .post-header. State transitions:
// 0 → 1+ recipients : "just me, X" turns into // 0 → 1+ recipients : "just me, X" turns into
// "shared between {chip}" + "& me, X" // "shared between {chip}" + "& me, X"

View File

@@ -0,0 +1,4 @@
{% load lyric_extras %}
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
<span class="bud-name">{{ item|display_name }}</span>
</li>

View File

@@ -0,0 +1,3 @@
<li class="applet-list-entry post-entry">
<a href="{{ item.get_absolute_url }}">{{ item.title }}</a>
</li>

View File

@@ -1,30 +0,0 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Dashbuddies{% endblock title_text %}
{% block header_text %}<span>Dash</span>buddies{% endblock header_text %}
{% block content %}
<div class="buddies-page">
<header class="buddies-header">
<h3 class="buddies-title">My Buddies</h3>
</header>
<ul id="id_buddies_list" class="buddies-list">
{% for buddy in buddies %}
<li class="buddy-entry" data-buddy-id="{{ buddy.id }}">
<span class="buddy-name">{{ buddy|display_name }}</span>
</li>
{% empty %}
<li class="buddy-entry buddy-entry--empty">No buddies yet.</li>
{% endfor %}
<li class="buddy-entry-buffer" aria-hidden="true"></li>
</ul>
{# Buddy btn (bottom-left) + slide-out add-buddy panel — async POST #}
{# to add_buddy. Mirror of the post.html share buddy btn but distinct #}
{# action (adds to User.buddies M2M) and DOM hooks (#id_buddies_list).#}
{% include "apps/billboard/_partials/_buddy_add_panel.html" %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,15 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Billbuds{% endblock title_text %}
{% block header_text %}<span>Bill</span>buds{% endblock header_text %}
{% block content %}
<div class="applet-list-page applet-list-page--single">
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="My Buds" shell_list_id="id_buds_list" shell_items=buds shell_item_template="apps/billboard/_partials/_my_buds_item.html" shell_empty="No buds yet." %}
</div>
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
{% endblock content %}

View File

@@ -1,20 +1,15 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Dashposts{% endblock title_text %} {% block title_text %}Billposts{% endblock title_text %}
{% block header_text %}<span>Dash</span>posts{% endblock header_text %} {% block header_text %}<span>Bill</span>posts{% endblock header_text %}
{% block content %} {% block content %}
<h3>{{ owner|display_name }}'s posts</h3> {# Two applet-scroll sections — own posts + posts shared with me. #}
<ul> {# Stack vertically in portrait, sit side-by-side in landscape (.--two-up).#}
{% for post in owner.posts.all %} <div class="applet-list-page applet-list-page--two-up">
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li> {% include "apps/applets/_partials/_applet-list-shell.html" with shell_title=owner|display_name|add:" posts" shell_items=owner.posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="No posts yet." %}
{% endfor %} {% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="Shared with me" shell_items=owner.shared_posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="Nothing shared yet." %}
</ul> </div>
<h3>Posts shared with me</h3>
<ul>
{% for post in owner.shared_posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
{% endblock content %} {% endblock content %}

View File

@@ -1,8 +1,8 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Dashpost{% endblock title_text %} {% block title_text %}Billpost{% endblock title_text %}
{% block header_text %}<span>Dash</span>post{% endblock header_text %} {% block header_text %}<span>Bill</span>post{% endblock header_text %}
{% block content %} {% block content %}
@@ -69,11 +69,11 @@
</form> </form>
{% endif %} {% endif %}
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #} {# Bud btn (bottom-left) + slide-out recipient field — async share. #}
{# Suppressed on admin Posts (note unlock thread) since friend-invites #} {# Suppressed on admin Posts (note unlock thread) since friend-invites #}
{# don't apply to system-authored threads. #} {# don't apply to system-authored threads. #}
{% if post.kind != 'note_unlock' %} {% if post.kind != 'note_unlock' %}
{% include "apps/billboard/_partials/_buddy_panel.html" %} {% include "apps/billboard/_partials/_bud_panel.html" %}
{% endif %} {% endif %}
</div> </div>
{% endblock content %} {% endblock content %}