bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.

Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).

URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).

Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.

mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.

Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.

JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.

SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).

Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.

Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.

1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-28 11:45:20 -04:00
parent c41cf7ed36
commit 6cc11924e3
23 changed files with 1423 additions and 28 deletions

View File

@@ -29,12 +29,18 @@ from apps.lyric.models import get_or_create_mailman, resolve_pronouns
from apps.lyric.templatetags.lyric_extras import at_handle
# Invite prose shown to the invitee. `{handle}` = the owner's `@username`
# (via at_handle — falls back to truncated email for handle-less owners);
# Invite prose shown to the invitee. `{handle}` is wrapped in an
# `<a class="post-attribution">` whose href routes to the owner's per-bud
# landing page — bud landing page sprint 2026-05-27 replaced the in-Line
# OK/BYE form-button block w. this navigational anchor. post.html's
# `safe`-filter branch is gated on `line.author.username == 'mailman'`
# (alongside 'adman'/'taxman') so the anchor renders as HTML.
#
# `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the
# table reads as the owner's. Em dash matches the @taxman "Look!—" house style.
INVITE_TEMPLATE = (
"Listen!—{handle} invites you to {poss} drawing table. "
'Listen!—<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>'
" invites you to {poss} drawing table. "
"This invite will expire 24h from the time it was extended."
)
@@ -67,6 +73,7 @@ def log_sea_invite(sea_invite):
post.save(update_fields=["title"])
text = INVITE_TEMPLATE.format(
owner_id=owner.id,
handle=at_handle(owner),
poss=resolve_pronouns(owner.pronouns)[2],
)

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-05-28 15:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0009_alter_brief_kind_alter_post_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BudshipNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shoptalk', models.CharField(default='', max_length=160)),
('edited_at', models.DateTimeField(auto_now=True)),
('bud', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_about', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_written', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-edited_at',),
'unique_together': {('user', 'bud')},
},
),
]

View File

@@ -260,3 +260,33 @@ def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
return
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
instance.delete()
class BudshipNote(models.Model):
"""Per-relation personal note about a bud — bud landing page sprint
2026-05-27 ([[project-bud-landing-page-sprint]]). One row per
(user, bud) pair: the user's own shoptalk about that bud, NEVER
visible to the bud. Lazy-created on first shoptalk save so the
absence of a row reads as 'never edited' (drives the `.tt-milestone`
slot on the My Buds tooltip portal — present when ≥1 edit, absent
otherwise)."""
user = models.ForeignKey(
"lyric.User",
on_delete=models.CASCADE,
related_name="budship_notes_written",
)
bud = models.ForeignKey(
"lyric.User",
on_delete=models.CASCADE,
related_name="budship_notes_about",
)
shoptalk = models.CharField(max_length=160, default="")
edited_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ("user", "bud")
ordering = ("-edited_at",)
def __str__(self):
return f"BudshipNote({self.user_id}{self.bud_id})"

View File

@@ -0,0 +1,103 @@
// Row-click → row-lock + tooltip-portal for the My Buds list.
//
// Bud landing page sprint 2026-05-27 ([[project-bud-landing-page-sprint]]).
// Mirrors apps/applets/row-lock.js's lock/unlock state machine, but binds
// to `.bud-entry` rows (which are NOT `.row-3col`) AND populates the
// shared `#id_tooltip_portal` from the row's data-tt-* attrs.
//
// Click target semantics:
// • Click on the inner `<a>` (the `@<handle>` anchor) — let it navigate
// to the bud's landing page. NO lock fires.
// • Click anywhere else inside `.bud-entry` — lock the row + open the
// tooltip portal w. its data-tt-* fields populated.
// • Click outside any `.bud-entry` (and outside the portal) — clear.
//
// `.tt-milestone` is REMOVED from the DOM (vs. emptied) when the row's
// `data-tt-milestone` attr is absent so the FT can distinguish "never
// edited" (slot absent) from "cleared after edit" (slot empty).
(function () {
'use strict';
var _lockedRow = null;
var _portal = null;
var _milestoneTemplate = null;
function _clearLock() {
if (_lockedRow) {
_lockedRow.classList.remove('row-locked');
_lockedRow = null;
}
if (_portal) _portal.classList.remove('active');
}
function _findSlot(name) {
if (!_portal) return null;
return _portal.querySelector('.tt-' + name);
}
function _populatePortal(row) {
if (!_portal) return;
var fields = ['title', 'description', 'email', 'shoptalk'];
fields.forEach(function (f) {
var slot = _findSlot(f);
if (slot) slot.textContent = row.dataset['tt' + f.charAt(0).toUpperCase() + f.slice(1)] || '';
});
// .tt-milestone — absent when never-edited; present (+ populated)
// when the row carries data-tt-milestone.
var ms = row.dataset.ttMilestone;
var existing = _findSlot('milestone');
if (ms) {
if (!existing) {
var span = _milestoneTemplate.cloneNode(true);
span.textContent = ms;
_portal.appendChild(span);
} else {
existing.textContent = ms;
}
} else {
if (existing) existing.remove();
}
}
function _onClick(e) {
// Anchor click — let navigation proceed; no lock/portal.
if (e.target.closest('.bud-entry .bud-name a')) {
_clearLock();
return;
}
var row = e.target.closest('.bud-entry');
if (row) {
if (row === _lockedRow) {
_clearLock();
} else {
_clearLock();
row.classList.add('row-locked');
_lockedRow = row;
_populatePortal(row);
if (_portal) _portal.classList.add('active');
}
return;
}
// Click inside the portal itself — preserve lock.
if (_portal && _portal.contains(e.target)) return;
_clearLock();
}
function _init() {
_portal = document.getElementById('id_tooltip_portal');
if (_portal) {
// Snapshot the milestone element shape so we can restore it
// after a never-edited row removes it.
var ms = _portal.querySelector('.tt-milestone');
if (ms) _milestoneTemplate = ms.cloneNode(false);
}
document.addEventListener('click', _onClick);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
}());

View File

@@ -114,6 +114,19 @@ class LogSeaInviteTest(TestCase):
def test_template_uses_listen_hook(self):
self.assertTrue(INVITE_TEMPLATE.startswith("Listen!"))
def test_line_wraps_owner_handle_in_post_attribution_anchor(self):
"""Bud landing page sprint 2026-05-27 — the inline OK/BYE block
migrates onto bud.html; the @mailman Line now carries an
`<a class="post-attribution">` around the owner's handle whose
href routes to the owner's per-bud landing page."""
_, line, _ = log_sea_invite(self.invite)
self.assertIn('class="post-attribution"', line.text)
self.assertIn(
f'href="/billboard/buds/{self.owner.id}/"', line.text,
)
# The anchor wraps ONLY the at_handle (not the surrounding prose)
self.assertIn(">@discoman</a>", line.text)
class MailAcceptanceKindTest(TestCase):
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""

View File

@@ -1140,3 +1140,350 @@ class BillboardAppletMySignTest(TestCase):
# Middle court has a suit, so the suit-icon `<i>` is present + carries
# the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc).
self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split()))
# ── Per-bud Landing Page ─────────────────────────────────────────────────
# /billboard/buds/<uuid:bud_id>/ + the my_buds row enrichment that surfaces
# the new tooltip-portal data — bud landing page sprint 2026-05-27 (see
# [[project-bud-landing-page-sprint]]). Replaces the @mailman invite Line's
# inline OK/BYE block w. a dedicated page; the My Buds list rows now wrap
# the `@<handle>` in an anchor to the bud's page + carry data-tt-* attrs
# the JS portal reads on row-lock click.
class BudPageRenderTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@buds.io", username="me")
self.alice = User.objects.create(email="alice@buds.io", username="alice")
self.user.buds.add(self.alice)
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertEqual(response.status_code, 200)
def test_uses_bud_template(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertTemplateUsed(response, "apps/billboard/bud.html")
def test_passes_bud_in_context(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertEqual(response.context["bud"], self.alice)
def test_passes_empty_shoptalk_when_no_note(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertEqual(response.context["shoptalk_text"], "")
self.assertIsNone(response.context["milestone_dt"])
def test_header_renders_at_handle_the_title_and_email(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
body = response.content.decode()
self.assertIn("@alice", body)
self.assertIn("the Earthman", body)
self.assertIn("alice@buds.io", body)
def test_shoptalk_textarea_carries_160_char_maxlength(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
body = response.content.decode()
self.assertRegex(
body, r'<textarea[^>]+id="id_shoptalk"[^>]*maxlength="160"',
)
def test_existing_shoptalk_renders_in_textarea(self):
from apps.billboard.models import BudshipNote
BudshipNote.objects.create(
user=self.user, bud=self.alice, shoptalk="loves chess",
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertEqual(response.context["shoptalk_text"], "loves chess")
self.assertIsNotNone(response.context["milestone_dt"])
self.assertContains(response, "loves chess")
class BudPageAutoAddOnFirstVisitTest(TestCase):
"""Visiting bud.html for a non-bud auto-adds them to the user's buds —
mirrors share_post's implicit-add posture so the @mailman post-
attribution anchor lands the inviter on the user's buds graph."""
def setUp(self):
self.user = User.objects.create(email="me@auto.io", username="me")
self.alice = User.objects.create(email="alice@auto.io", username="alice")
# alice is NOT in user.buds — auto-add is the contract
self.client.force_login(self.user)
def test_visit_adds_bud_to_m2m(self):
self.assertNotIn(self.alice, list(self.user.buds.all()))
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
self.assertIn(self.alice, list(self.user.buds.all()))
def test_self_visit_does_not_self_add(self):
# Pathological case: navigating to your own bud page must not seed
# the user as their own bud (M2M is asymmetric self-FK).
self.client.get(reverse("billboard:bud_page", args=[self.user.id]))
self.assertNotIn(self.user, list(self.user.buds.all()))
def test_already_bud_visit_is_idempotent(self):
self.user.buds.add(self.alice)
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
# M2M dedup'd; still one row
self.assertEqual(self.user.buds.filter(pk=self.alice.pk).count(), 1)
class BudPagePendingInviteCascadeTest(TestCase):
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired
PENDING SeaInvite exists from this bud (owner) to the viewer (invitee).
Reuses the same template flags `_burger.html` already reads on my_sea
+ room — no new template plumbing on bud.html."""
def setUp(self):
from apps.gameboard.models import SeaInvite
self.SeaInvite = SeaInvite
self.user = User.objects.create(email="me@inv.io", username="me")
self.alice = User.objects.create(email="alice@inv.io", username="alice")
self.user.buds.add(self.alice)
self.client.force_login(self.user)
def test_no_invite_no_cascade(self):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
self.assertFalse(response.context["sea_first_draw_pending"])
def test_pending_invite_lights_cascade(self):
self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.PENDING,
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNotNone(response.context["pending_invite"])
self.assertTrue(response.context["sea_btn_active"])
self.assertTrue(response.context["sea_first_draw_pending"])
def test_accepted_invite_does_not_cascade(self):
self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.ACCEPTED,
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
def test_expired_pending_invite_does_not_cascade(self):
inv = self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.PENDING,
)
self.SeaInvite.objects.filter(pk=inv.pk).update(
created_at=timezone.now() - timezone.timedelta(hours=48),
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
def test_invite_for_other_invitee_ignored(self):
# Pending invite from alice → some other user is irrelevant to ME.
other = User.objects.create(email="other@inv.io", username="other")
self.SeaInvite.objects.create(
owner=self.alice,
invitee=other,
invitee_email=other.email,
status=self.SeaInvite.PENDING,
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
class SaveBudShoptalkViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@sav.io", username="me")
self.alice = User.objects.create(email="alice@sav.io", username="alice")
self.user.buds.add(self.alice)
self.client.force_login(self.user)
def test_post_creates_budship_note(self):
from apps.billboard.models import BudshipNote
self.client.post(
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
{"shoptalk": "first thoughts"},
)
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
self.assertEqual(bn.shoptalk, "first thoughts")
def test_post_updates_existing_budship_note(self):
from apps.billboard.models import BudshipNote
BudshipNote.objects.create(user=self.user, bud=self.alice, shoptalk="old")
self.client.post(
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
{"shoptalk": "new"},
)
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
self.assertEqual(bn.shoptalk, "new")
def test_post_caps_at_160_chars(self):
from apps.billboard.models import BudshipNote
self.client.post(
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
{"shoptalk": "a" * 300},
)
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
self.assertLessEqual(len(bn.shoptalk), 160)
def test_get_returns_405(self):
response = self.client.get(
reverse("billboard:save_bud_shoptalk", args=[self.alice.id])
)
self.assertEqual(response.status_code, 405)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
{"shoptalk": "anon"},
)
self.assertEqual(response.status_code, 302)
class DeleteBudViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@del.io", username="me")
self.alice = User.objects.create(email="alice@del.io", username="alice")
self.user.buds.add(self.alice)
self.client.force_login(self.user)
def test_post_removes_bud_from_m2m(self):
self.client.post(
reverse("billboard:delete_bud", args=[self.alice.id])
)
self.assertNotIn(self.alice, list(self.user.buds.all()))
def test_post_redirects_to_my_buds(self):
response = self.client.post(
reverse("billboard:delete_bud", args=[self.alice.id])
)
self.assertRedirects(response, reverse("billboard:my_buds"))
def test_get_does_not_remove(self):
self.client.get(reverse("billboard:delete_bud", args=[self.alice.id]))
self.assertIn(self.alice, list(self.user.buds.all()))
class MyBudsRowEnrichmentTest(TestCase):
"""The my_buds page row now carries the data-tt-* attrs the tooltip
portal reads on row-lock click, plus an anchor wrapping the handle
that routes to the bud's landing page."""
def setUp(self):
self.user = User.objects.create(email="me@row.io", username="me")
self.alice = User.objects.create(email="alice@row.io", username="alice")
self.user.buds.add(self.alice)
self.client.force_login(self.user)
def test_row_carries_data_bud_id(self):
response = self.client.get(reverse("billboard:my_buds"))
self.assertContains(response, f'data-bud-id="{self.alice.id}"')
def test_row_carries_tt_title_description_email_attrs(self):
response = self.client.get(reverse("billboard:my_buds"))
self.assertContains(response, 'data-tt-title="@alice"')
self.assertContains(response, 'data-tt-description="Earthman"')
self.assertContains(response, 'data-tt-email="alice@row.io"')
def test_row_renders_at_handle_the_title(self):
response = self.client.get(reverse("billboard:my_buds"))
body = response.content.decode()
self.assertIn("@alice", body)
self.assertIn("the Earthman", body)
def test_username_wrapped_in_anchor_to_bud_page(self):
response = self.client.get(reverse("billboard:my_buds"))
body = response.content.decode()
bud_page_url = reverse("billboard:bud_page", args=[self.alice.id])
self.assertRegex(
body,
rf'<span class="bud-name"><a[^>]*href="{bud_page_url}"',
)
def test_row_carries_shoptalk_when_set(self):
from apps.billboard.models import BudshipNote
BudshipNote.objects.create(
user=self.user, bud=self.alice, shoptalk="dragonkin",
)
response = self.client.get(reverse("billboard:my_buds"))
self.assertContains(response, 'data-tt-shoptalk="dragonkin"')
self.assertContains(response, "data-tt-milestone=")
def test_row_carries_empty_shoptalk_attr_when_never_edited(self):
response = self.client.get(reverse("billboard:my_buds"))
self.assertContains(response, 'data-tt-shoptalk=""')
def test_row_omits_milestone_when_no_note(self):
response = self.client.get(reverse("billboard:my_buds"))
body = response.content.decode()
self.assertNotIn("data-tt-milestone=", body)
class BudshipNoteModelTest(TestCase):
"""`BudshipNote(user, bud, shoptalk, edited_at)` — per-relation note."""
def setUp(self):
self.user = User.objects.create(email="me@m.io", username="me")
self.bud = User.objects.create(email="b@m.io", username="b")
def test_unique_per_user_bud_pair(self):
from django.db import IntegrityError
from apps.billboard.models import BudshipNote
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="x")
with self.assertRaises(IntegrityError):
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="y")
def test_edited_at_updates_on_save(self):
from apps.billboard.models import BudshipNote
bn = BudshipNote.objects.create(
user=self.user, bud=self.bud, shoptalk="first",
)
first_ts = bn.edited_at
bn.shoptalk = "second"
bn.save()
self.assertGreaterEqual(bn.edited_at, first_ts)
def test_shoptalk_max_length_160(self):
from apps.billboard.models import BudshipNote
f = BudshipNote._meta.get_field("shoptalk")
self.assertEqual(f.max_length, 160)

View File

@@ -23,6 +23,9 @@ urlpatterns = [
path("my-buds/", views.my_buds, name="my_buds"),
path("buds/add", views.add_bud, name="add_bud"),
path("buds/search", views.search_buds, name="search_buds"),
path("buds/<uuid:bud_id>/", views.bud_page, name="bud_page"),
path("buds/<uuid:bud_id>/shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"),
path("buds/<uuid:bud_id>/delete", views.delete_bud, name="delete_bud"),
path("my-sign/", views.my_sign, name="my_sign"),
path("my-sign/save", views.save_sign, name="save_sign"),
path("my-sign/clear", views.clear_sign, name="clear_sign"),

View File

@@ -10,7 +10,7 @@ from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.billboard.forms import ExistingPostLineForm, LineForm
from apps.billboard.models import Brief, Line, Post
from apps.billboard.models import Brief, BudshipNote, Line, Post
from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
@@ -567,12 +567,96 @@ def share_post(request, post_id):
@login_required(login_url="/")
def my_buds(request):
"""My Buds page — enriched per-row w. shoptalk + milestone for the
tooltip portal (bud landing page sprint 2026-05-27). Attaches
`.shoptalk_text` + `.milestone_dt` to each bud User so the row
template can render data-tt-* attrs without an extra template tag."""
notes_by_bud = {
bn.bud_id: bn
for bn in BudshipNote.objects.filter(user=request.user)
}
buds = list(request.user.buds.all().select_related("active_title"))
for bud in buds:
bn = notes_by_bud.get(bud.id)
bud.shoptalk_text = bn.shoptalk if bn else ""
bud.milestone_dt = bn.edited_at if bn else None
return render(request, "apps/billboard/my_buds.html", {
"buds": request.user.buds.all(),
"buds": buds,
"page_class": "page-billbuds",
})
# ── Per-bud landing page ───────────────────────────────────────────────────
# /billboard/buds/<bud_id>/ + shoptalk save + bud delete — see
# [[project-bud-landing-page-sprint]]. Replaces the @mailman invite Line's
# inline OK/BYE block w. a dedicated surface; bud.html is also the click
# target of the My Buds row's `@<handle>` anchor.
@login_required(login_url="/")
def bud_page(request, bud_id):
"""Render the per-bud landing page. Auto-adds the bud on first visit
(mirrors share_post's implicit-add posture) so following the @mailman
post-attribution anchor from an invite Brief grows the buds graph
without an explicit add step. Self-visits are no-op for the auto-add
branch — users don't accumulate themselves as a bud.
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
the same template variables `_burger.html` already reads on my_sea +
room — server-side conditional renders `glow-handoff` on the burger
+ `.active` on the sea sub-btn. The flags fire iff a non-expired
PENDING SeaInvite exists from this bud to the viewer."""
from django.shortcuts import get_object_or_404
from apps.gameboard.models import SeaInvite
bud = get_object_or_404(User, id=bud_id)
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
request.user.buds.add(bud)
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
pending = (
SeaInvite.objects
.filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING)
.order_by("-created_at")
.first()
)
if pending is not None and pending.is_expired:
pending = None
return render(request, "apps/billboard/bud.html", {
"bud": bud,
"shoptalk_text": bn.shoptalk if bn else "",
"milestone_dt": bn.edited_at if bn else None,
"pending_invite": pending,
"sea_btn_active": pending is not None,
"sea_first_draw_pending": pending is not None,
"page_class": "page-billbud",
})
@login_required(login_url="/")
def save_bud_shoptalk(request, bud_id):
"""POST-only — upsert a BudshipNote w. up to 160 chars of shoptalk."""
from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
bud = get_object_or_404(User, id=bud_id)
text = (request.POST.get("shoptalk") or "")[:160]
BudshipNote.objects.update_or_create(
user=request.user, bud=bud,
defaults={"shoptalk": text},
)
return JsonResponse({"ok": True, "shoptalk": text})
@login_required(login_url="/")
def delete_bud(request, bud_id):
"""POST-only — remove the bud from the user's M2M; redirect to my_buds.
GET is a silent no-op redirect (no membership change)."""
from django.shortcuts import get_object_or_404
if request.method == "POST":
bud = get_object_or_404(User, id=bud_id)
request.user.buds.remove(bud)
return redirect("billboard:my_buds")
def _resolve_recipient(raw):
"""Resolve a free-form recipient (email OR username) to a User, or None.
Email match takes precedence — if the input contains '@' we don't even

View File

@@ -138,8 +138,11 @@ class MySeaInviteAcceptDeclineTest(TestCase):
class MySeaInvitePostRenderTest(TestCase):
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
a status badge otherwise, all driven by `line.sea_invite.status`."""
"""post.html — the @mailman invite Line carries a post-attribution
anchor around the owner's handle whose href routes to the owner's
per-bud landing page. Bud landing page sprint 2026-05-27 ([[project-
bud-landing-page-sprint]]) migrated the prior inline OK/BYE/Accepted
block onto bud.html; this class pins the new prose contract."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="discoman")
@@ -154,26 +157,31 @@ class MySeaInvitePostRenderTest(TestCase):
self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id])
def test_pending_invite_renders_ok_bye(self):
def test_pending_invite_renders_post_attribution_anchor_not_buttons(self):
content = self.client.get(self.post_url).content.decode()
self.assertIn("invite-ok-btn", content)
self.assertIn("invite-bye-btn", content)
self.assertIn(self.accept_url, content)
self.assertIn(self.decline_url, content)
# OK/BYE buttons + accept/decline form actions migrated onto bud.html.
self.assertNotIn("invite-ok-btn", content)
self.assertNotIn("invite-bye-btn", content)
self.assertNotIn(self.accept_url, content)
self.assertNotIn(self.decline_url, content)
# Anchor wraps the owner's handle, routing to their bud landing page.
self.assertIn('class="post-attribution"', content)
self.assertIn(f"/billboard/buds/{self.owner.id}/", content)
def test_accepted_invite_renders_badge_not_buttons(self):
def test_accepted_invite_renders_no_badge(self):
self.invite.status = SeaInvite.ACCEPTED
self.invite.accepted_at = timezone.now()
self.invite.save()
content = self.client.get(self.post_url).content.decode()
self.assertIn("Accepted", content)
# No "Accepted <date>" badge — the sweep is unconditional.
self.assertNotIn("invite-badge--accepted", content)
self.assertNotIn(self.accept_url, content)
def test_declined_invite_renders_declined_badge(self):
def test_declined_invite_renders_no_badge(self):
self.invite.status = SeaInvite.DECLINED
self.invite.save()
content = self.client.get(self.post_url).content.decode()
self.assertIn("Declined", content)
self.assertNotIn("invite-badge--declined", content)
self.assertNotIn(self.accept_url, content)
def test_mailman_line_renders_as_system_with_handles(self):

View File

@@ -4,6 +4,35 @@ def user_palette(request):
return {"user_palette": "palette-default"}
def mail_brief_payload(request):
"""Inject the user's oldest unread @mailman "Acceptances & rejections"
Brief into every authenticated response context — bud landing page
sprint 2026-05-27 ([[project-bud-landing-page-sprint]]). The base
template renders the payload as a JSON script + auto-fires
Brief.showBanner so the slide-down notification surfaces on the next
page load regardless of where the invitee lands.
Marked read by `view_post`'s existing unread-flip on GET of the
underlying MAIL_ACCEPTANCE Post — same mark-read contract every other
Brief kind already uses."""
if not request.user.is_authenticated:
return {}
from apps.billboard.models import Brief
brief = (
Brief.objects
.filter(
owner=request.user,
kind=Brief.KIND_MAIL_ACCEPTANCE,
is_unread=True,
)
.order_by("created_at")
.first()
)
if brief is None:
return {}
return {"mail_brief_payload": brief.to_banner_dict()}
def navbar_context(request):
if not request.user.is_authenticated:
return {}

View File

@@ -105,6 +105,7 @@ TEMPLATES = [
'django.contrib.messages.context_processors.messages',
'core.context_processors.user_palette',
'core.context_processors.navbar_context',
'core.context_processors.mail_brief_payload',
],
},
},

View File

@@ -0,0 +1,276 @@
"""FT for the per-bud landing page at /billboard/buds/<bud_id>/.
bud.html is the destination of the @mailman-invite cascade (see
test_bill_mailman_invite_post.py) AND the click target of the
`@<handle>` anchor on /billboard/my-buds/ (see test_bill_my_buds_tooltip.py).
It's the singular counterpart of my_buds.html in the same way post.html
is to my_posts.html.
Spec recap:
- Page renders all the data the tooltip surfaces (title, description, email,
shoptalk, milestone) PLUS a 160-char `<textarea>` so the user can edit
the shoptalk in place. Save persists to a per-relationship BudshipNote.
- 4-button apparatus from my_sea.html: kit / bud / gear / burger.
- The bud `#id_bud_btn` opens but its OK button is disabled (.btn-disabled + ×).
- Gear menu = NVM (→ my_buds) + DEL (→ shared guard portal "Delete this bud?";
DEL deletes the M2M edge, NVM dismisses the portal).
- Burger fan: 5 sub-btns default to inactive (.fa-ban icon, no .active class).
- Invite-cascade: if the bud has a PENDING SeaInvite where invitee=me,
• #id_burger_btn carries .glow-handoff (machine cascade from my_sea.html);
• #id_sea_btn in the fan is .active;
• clicking the glowing #id_sea_btn navigates to the bud's my-sea page
AND clears .glow-handoff but PRESERVES .active.
- Visiting bud.html for an inviter who isn't yet in my buds auto-adds them.
"""
from selenium.webdriver.common.by import By
from apps.gameboard.models import SeaInvite
from apps.lyric.models import User
from .base import FunctionalTest
class BudPageRenderTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_page_header_reads_at_handle_the_title(self):
"""Same `@alice the Earthman` framing as the my_buds row, hoisted
into the page header so the user sees who they're on the page of."""
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
header = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".bud-page-header"
))
self.assertIn("@alice the Earthman", header.text)
self.assertIn("alice@test.io", header.text)
def test_shoptalk_textarea_renders_with_160_char_maxlength(self):
textarea = self.wait_for(lambda: self._open_and_find_textarea())
self.assertEqual(textarea.get_attribute("maxlength"), "160")
self.assertEqual(textarea.get_attribute("value") or "", "")
def _open_and_find_textarea(self):
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
return self.browser.find_element(By.ID, "id_shoptalk")
class BudPageFourButtonApparatusTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
def test_all_four_apparatus_buttons_present(self):
"""kit / bud / gear / burger — same 4-btn slate as my_sea.html.
kit_btn is included via base.html so we don't need to render it
specially on bud.html."""
for btn_id in ("id_kit_btn", "id_bud_btn", "id_burger_btn"):
self.wait_for(lambda bid=btn_id: self.browser.find_element(By.ID, bid))
# Gear btn is rendered via the shared _gear.html partial — class-
# only, not an id. Disambiguate by data-menu-target.
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
))
def test_bud_btn_panel_ok_is_disabled_and_shows_times(self):
"""Per-spec stub: the bud-on-bud OK btn is intentionally disabled
for now (.btn-disabled + `×`). Wired later when the bud-of-bud
flow exists. Slap-on stub avoids removing the apparatus."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
ok = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn"
))
self.assertIn("btn-disabled", ok.get_attribute("class") or "")
self.assertEqual(ok.text.strip(), "×")
def test_burger_fan_all_five_subbtns_inactive_by_default(self):
"""Fresh load (no pending SeaInvite to me) → every sub-btn renders
WITHOUT .active. The visible icon for each sub-btn is .fa-ban (the
`--off` half); the `--on` icon stays hidden by CSS until .active."""
burger = self.browser.find_element(By.ID, "id_burger_btn")
burger.click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_element(By.ID, "id_burger_fan").get_attribute("aria-hidden"),
"false",
))
fan_btns = self.browser.find_elements(By.CSS_SELECTOR, "#id_burger_fan .burger-fan-btn")
self.assertEqual(len(fan_btns), 5)
for b in fan_btns:
cls = b.get_attribute("class") or ""
self.assertNotIn("active", cls.split())
class BudPageGearMenuTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
def test_gear_nvm_navigates_back_to_my_buds(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
nvm = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-cancel"
))
self.assertEqual(nvm.text.strip(), "NVM")
nvm.click()
self.wait_for(lambda: self.assertIn(
"/billboard/my-buds/", self.browser.current_url
))
def test_gear_del_opens_guard_portal_with_delete_this_bud_message(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
del_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
))
self.assertEqual(del_btn.text.strip(), "DEL")
del_btn.click()
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active"
))
self.assertIn(
"Delete this bud?",
portal.find_element(By.CSS_SELECTOR, ".guard-message").text,
)
def test_guard_del_removes_bud_from_m2m_and_navigates_away(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
)).click()
# Guard portal: DEL = .guard-yes, NVM = .guard-no.
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
)).click()
self.wait_for(lambda: self.assertIn(
"/billboard/my-buds/", self.browser.current_url
))
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
def test_guard_nvm_dismisses_portal_without_navigating_or_deleting(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
)).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active .guard-no"
)).click()
# Portal closes; bud is still in M2M; we're still on the bud page.
self.wait_for(lambda: self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_guard_portal").get_attribute("class") or "",
))
self.assertIn(self.alice, list(self.gamer.buds.all()))
self.assertIn(f"/billboard/buds/{self.alice.id}/", self.browser.current_url)
class BudPageInviteCascadeTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_no_pending_invite_no_glow_no_sea_active(self):
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
self.assertNotIn("glow-handoff", (burger.get_attribute("class") or "").split())
burger.click()
sea = self.browser.find_element(By.ID, "id_sea_btn")
self.assertNotIn("active", (sea.get_attribute("class") or "").split())
def test_pending_invite_glows_burger_and_seats_sea_btn_active(self):
"""alice → me PENDING SeaInvite. burger.glow-handoff is rendered
server-side (machine cascade — same `glow-handoff` class as the
my_sea.html sea_first_draw_pending path)."""
SeaInvite.objects.create(
owner=self.alice,
invitee_email=self.gamer.email,
invitee=self.gamer,
status=SeaInvite.PENDING,
)
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
self.assertIn("glow-handoff", (burger.get_attribute("class") or "").split())
burger.click()
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
self.assertIn("active", (sea.get_attribute("class") or "").split())
def test_click_glowing_sea_btn_navigates_to_buds_my_sea_and_clears_glow(self):
"""Click glowing #id_sea_btn → navigates to alice's my-sea spectator
page. The .active stays on sea_btn (preserved across pages); the
burger's .glow-handoff is consumed by the handoff."""
SeaInvite.objects.create(
owner=self.alice,
invitee_email=self.gamer.email,
invitee=self.gamer,
status=SeaInvite.PENDING,
)
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn")).click()
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
sea.click()
# Final URL is the bud's my-sea spectator page — exact route TBD
# at IT time (likely /gameboard/my-sea/spectate/<owner_id>/ or
# /gameboard/my-sea/<owner_id>/). The FT pins the OWNER's id in
# the URL so the IT picks a route shape that surfaces it.
self.wait_for(lambda: self.assertIn(
str(self.alice.id), self.browser.current_url
))
self.wait_for(lambda: self.assertIn("my-sea", self.browser.current_url))
class BudPageAutoAddOnFirstVisitTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
# NOTE: alice is NOT yet in gamer.buds — visiting bud.html for her
# via the post-attribution link should auto-add her.
self.create_pre_authenticated_session("me@test.io")
def test_visiting_bud_page_for_non_bud_auto_adds_to_m2m(self):
"""Per-spec: 'if the inviter wasn't already a bud, navigating there
as such automatically adds the bud to the user's bud list.' Same
implicit-add posture as share-post → buds.add."""
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".bud-page-header"
))
self.assertIn(self.alice, list(self.gamer.buds.all()))

View File

@@ -0,0 +1,168 @@
"""FT for the @mailman "Acceptances & rejections" Post + cross-page Brief
behaviour after the bud-landing-page sprint refactor.
Pre-sprint: the @mailman invite Line carried inline OK/BYE form buttons
+ an "Accepted <date>" badge (see apps/billboard/_partials/_invite_actions.html).
Post-sprint: that interaction surface migrates entirely onto bud.html —
the Line's prose becomes the interactive surface via an
`<a class="post-attribution">@<owner></a>` inside `span.post-line-text`
whose href routes to /billboard/buds/<owner_id>/. The bud.html page then
absorbs the accept/decline + spectator-link flow that used to live in the
Post.
Spec recap:
- When the bud has invited the user, a Brief slides down wherever the user
is on the site; FYI links to the @mailman Acceptances & rejections Post.
- The Post's mailman Line contains an `a.post-attribution` around the
inviter's handle, href=/billboard/buds/<inviter_id>/.
- The Line carries NO `.invite-actions` block (OK/BYE/Accepted/etc. removed).
- Following the post-attribution link auto-adds the inviter to the user's
buds list if not already a bud (verified separately in
test_bill_bud_page.py::BudPageAutoAddOnFirstVisitTest).
"""
from selenium.webdriver.common.by import By
from apps.billboard.mail import log_sea_invite
from apps.gameboard.models import SeaInvite
from apps.lyric.models import User, get_or_create_mailman
def _create_pending_invite(owner, invitee):
"""Reusable helper: alice → me PENDING SeaInvite + the @mailman Post +
Line + Brief log entry. Mirrors what gameboard.invite-bud would create
in real flow; calling log_sea_invite directly avoids dragging the
bud-invite POST view into the FT setup."""
inv = SeaInvite.objects.create(
owner=owner,
invitee_email=invitee.email,
invitee=invitee,
status=SeaInvite.PENDING,
)
log_sea_invite(inv)
return inv
from .base import FunctionalTest
class MailmanPostStructureTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
get_or_create_mailman()
_create_pending_invite(self.alice, self.gamer)
self.create_pre_authenticated_session("me@test.io")
def _navigate_to_mailman_post(self):
"""Land on the user's @mailman post directly via the my_posts list.
Avoids hard-coding the post UUID."""
from apps.billboard.models import Post
post = Post.objects.get(owner=self.gamer, kind=Post.KIND_MAIL_ACCEPTANCE)
self.browser.get(
self.live_server_url + f"/billboard/post/{post.id}/"
)
def test_mailman_line_contains_post_attribution_anchor_to_bud_page(self):
"""span.post-line-text on the @mailman Line wraps the inviter's
handle in an <a class="post-attribution"> whose href is the bud
landing page for the inviter. Anchor text = the bud's at_handle
(e.g. '@alice'); the surrounding prose stays in plain text."""
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
anchor = line.find_element(
By.CSS_SELECTOR, "span.post-line-text a.post-attribution"
)
self.assertEqual(anchor.text, "@alice")
href = anchor.get_attribute("href")
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)
def test_mailman_line_no_longer_renders_ok_bye_or_accepted_badge(self):
"""The pre-sprint .invite-actions block is GONE — the entire
accept/decline + accepted-date UX migrates onto bud.html. The Line
carries prose only."""
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-ok-btn"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-bye-btn"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-badge--accepted"),
[],
)
def test_mailman_line_no_invite_actions_remain_after_invite_accepted(self):
"""Even if the SeaInvite is in ACCEPTED state, the Line stays prose-
only — there's no longer an 'Accepted <date>' badge here (that
information surfaces on bud.html instead). Asserts the .invite-
actions sweep is unconditional, not just PENDING-only."""
SeaInvite.objects.filter(invitee=self.gamer).update(status=SeaInvite.ACCEPTED)
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
[],
)
class MailmanBriefSurfacesOnAnyPageTest(FunctionalTest):
"""The Brief spawned by `log_sea_invite` should surface wherever the
user lands next — not only on the Post itself. Existing Brief delivery
is page-load based (banner injected by the server-rendered template +
note.js Brief.showBanner). The FT verifies the cross-page surface by
visiting an unrelated page first."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
get_or_create_mailman()
_create_pending_invite(self.alice, self.gamer)
self.create_pre_authenticated_session("me@test.io")
def test_brief_appears_on_unrelated_page_load(self):
"""Land on `/` (not the Post itself). The Brief banner should
still be present — the invite spawned an unread Brief, which
the next page-load surfaces sitewide via the `mail_brief_
payload` context processor + base.html auto-showBanner."""
self.browser.get(self.live_server_url + "/")
banner = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner"
))
self.assertIn(
"Acceptances & rejections",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
def test_brief_fyi_navigates_to_mailman_post(self):
"""FYI link on the slide-down banner is the cross-page nav into the
Acceptances & rejections Post. Click → land on the Post page."""
self.browser.get(self.live_server_url + "/")
fyi = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner__fyi"
))
fyi.click()
# Post URL pattern is /billboard/post/<uuid>/
self.wait_for(lambda: self.assertIn(
"/billboard/post/", self.browser.current_url
))
# And the page is the user's @mailman post (title visible).
self.wait_for(lambda: self.assertIn(
"Acceptances & rejections",
self.browser.find_element(By.CSS_SELECTOR, ".post-title").text,
))

View File

@@ -0,0 +1,140 @@
"""FT for the My Buds page — line rendering w. donned title + click-lock
tooltip portal + username <a> link to the per-bud landing page.
Companion to test_bill_my_buds.py (which covers Phase 1 add/render).
This file covers the My Buds enrichment that lands alongside the new
apps/billboard/bud.html landing page (see test_bill_bud_page.py).
Spec recap:
- Each bud row reads `@<handle> the <Title>` (at_handle + active_title_display).
- Clicking the row applies `.row-locked` to the `li.applet-list-entry` AND
opens the shared #id_tooltip_portal w. five fields:
.tt-title → `@<handle>`
.tt-description → bud's donned title ("Earthman" by default)
.tt-email → bud's email (small / italic / 0.75 opacity — CSS)
.tt-shoptalk → the user's personal note about this bud (160-char cap)
.tt-milestone → "edited <relative-ts>" (omitted when no edit yet)
- The `@<handle>` itself is an `<a>` element (--terUser by CSS) whose href
navigates to the bud's landing page /billboard/buds/<bud_id>/.
"""
from selenium.webdriver.common.by import By
from apps.lyric.models import User
from .base import FunctionalTest
class MyBudsLineRendersBudTitleTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_bud_entry_reads_at_handle_the_title(self):
"""Default (no donned Note) → '@alice the Earthman' — active_title_display
falls back to 'Earthman' when the bud hasn't donned any Note title.
Renders inside .bud-entry; the username span carries the `.bud-name`
class for backward compat w. test_bill_my_buds.py."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
entry = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
# Full row text combines handle + title
self.assertIn("@alice the Earthman", entry.text)
class MyBudsClickOpensTooltipPortalTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_click_locks_row_and_opens_tooltip_portal(self):
"""Click the bud row → li picks up .row-locked AND the shared
#id_tooltip_portal becomes .active w. the bud's data populated
into .tt-title / .tt-description / .tt-email."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
row = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
self.assertNotIn("row-locked", row.get_attribute("class") or "")
row.click()
# Row picks up .row-locked
self.wait_for(lambda: self.assertIn(
"row-locked", row.get_attribute("class") or ""
))
# Portal slides in active
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
# Field assertions — text() suffices; the SCSS rules (italic,
# opacity, glow) are out of FT scope.
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
desc = portal.find_element(By.CSS_SELECTOR, ".tt-description").text
email = portal.find_element(By.CSS_SELECTOR, ".tt-email").text
self.assertEqual(title, "@alice")
self.assertEqual(desc, "Earthman")
self.assertEqual(email, "alice@test.io")
def test_tooltip_milestone_absent_when_shoptalk_never_edited(self):
"""`.tt-milestone` renders only when the user has edited the shoptalk
for this bud — fresh bud has no BudshipNote row yet, so no edit
timestamp exists. The cell should be ABSENT (not empty)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
row = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
row.click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, "#id_tooltip_portal .tt-milestone"),
[],
)
def test_tooltip_shoptalk_empty_string_when_never_edited(self):
"""Shoptalk slot still renders (so the layout slot exists), just
with empty body. Distinguishes never-edited (slot empty) from
cleared-after-edit (slot empty + .tt-milestone present)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
).click()
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
# .tt-shoptalk exists but is empty
shoptalk = portal.find_element(By.CSS_SELECTOR, ".tt-shoptalk")
self.assertEqual(shoptalk.text.strip(), "")
class MyBudsUsernameAnchorTest(FunctionalTest):
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="me@test.io", username="me")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_username_inside_row_is_anchor_to_bud_page(self):
"""The `@alice` text itself is wrapped in an `<a>` element (--terUser
per global SCSS); href routes to /billboard/buds/<bud_id>/. The row
wrapper still receives clicks for the row-lock + tooltip flow (the
anchor is a CHILD of the bud-name span; clicking the row outside
the anchor locks; clicking the anchor itself navigates)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
anchor = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR,
f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name a",
))
self.assertEqual(anchor.text, "@alice")
href = anchor.get_attribute("href")
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)

View File

@@ -86,6 +86,7 @@
#id_billboard_applet_menu { @extend %applet-menu; }
#id_billscroll_menu { @extend %applet-menu; }
#id_my_sea_menu { @extend %applet-menu; }
#id_bud_menu { @extend %applet-menu; }
// Page-level gear buttons — fixed to viewport bottom-right
.gameboard-page,
@@ -95,7 +96,8 @@
.post-page,
.billboard-page,
.billscroll-page,
.my-sea-page {
.my-sea-page,
.bud-page {
> .gear-btn {
position: fixed;
bottom: 4.2rem;
@@ -112,7 +114,8 @@
#id_post_menu,
#id_billboard_applet_menu,
#id_billscroll_menu,
#id_my_sea_menu {
#id_my_sea_menu,
#id_bud_menu {
position: fixed;
bottom: 6.6rem;
right: 1rem;
@@ -137,7 +140,8 @@
.post-page > .gear-btn,
.billboard-page > .gear-btn,
.billscroll-page > .gear-btn,
.my-sea-page > .gear-btn {
.my-sea-page > .gear-btn,
.bud-page > .gear-btn {
top: 0.5rem;
bottom: auto;
right: 4.2rem;
@@ -151,7 +155,8 @@
#id_post_menu,
#id_billboard_applet_menu,
#id_billscroll_menu,
#id_my_sea_menu {
#id_my_sea_menu,
#id_bud_menu {
position: fixed;
top: 2.6rem;
right: 4.2rem;

View File

@@ -123,9 +123,15 @@
}
// Active sub-btn = fully visible; inactive (default) = 0.6 opacity hint.
// Active conditions are wired one-by-one in later sprints.
// `z-index: 1` raises the active sub-btn above its inactive siblings so
// click hit-testing picks the active one even while the fan is mid-
// transition (during the 0.25s arc-out animation all 5 sub-btns share
// the fan-centre origin, and a later-in-DOM inactive btn would otherwise
// obscure the active target). Active conditions are wired one-by-one in
// later sprints.
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn.active {
opacity: 1;
z-index: 1;
}
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn:not(.active) {
opacity: 0.6;

View File

@@ -0,0 +1,11 @@
{# Gear menu on bud.html — mirrors apps/billboard/_partials/_post_gear.html. #}
{# NVM returns to /billboard/my-buds/; DEL opens the shared #id_guard_portal #}
{# from base.html via the `data-confirm` hook + POSTs to delete_bud. #}
<div id="id_bud_menu" style="display:none">
<a href="{% url 'billboard:my_buds' %}" class="btn btn-cancel">NVM</a>
<form method="POST" action="{% url 'billboard:delete_bud' bud.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger" data-confirm="Delete this bud?">DEL</button>
</form>
</div>
{% include "apps/applets/_partials/_gear.html" with menu_id="id_bud_menu" %}

View File

@@ -0,0 +1,13 @@
{# Shared tooltip-portal markup for the My Buds list. Hidden by default; #}
{# JS reads data-tt-* attrs from the clicked .bud-entry row, populates #}
{# the slots below, and toggles .active. .tt-milestone is removed from #}
{# the DOM when no data-tt-milestone attr is present so the FT can #}
{# distinguish never-edited from cleared-after-edit (slot absent vs. #}
{# present-but-empty). #}
<div id="id_tooltip_portal" class="tt">
<span class="tt-title"></span>
<span class="tt-description"></span>
<span class="tt-email"></span>
<span class="tt-shoptalk"></span>
<span class="tt-milestone"></span>
</div>

View File

@@ -1,4 +1,15 @@
{% load lyric_extras %}
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
<span class="bud-name">{{ item|at_handle }}</span>
{# My Buds page row — `@<handle> the <Title>` w. anchor on the handle #}
{# routing to the bud's landing page + data-tt-* attrs the tooltip #}
{# portal reads on row-lock click. `item.shoptalk_text` + `.milestone_ #}
{# dt` are attached by the my_buds view from BudshipNote. #}
<li class="applet-list-entry bud-entry"
data-bud-id="{{ item.id }}"
data-tt-title="{{ item|at_handle }}"
data-tt-description="{{ item.active_title_display }}"
data-tt-email="{{ item.email }}"
data-tt-shoptalk="{{ item.shoptalk_text|default:'' }}"
{% if item.milestone_dt %}data-tt-milestone="edited {{ item.milestone_dt|relative_ts }}"{% endif %}>
<span class="bud-name"><a href="{% url 'billboard:bud_page' item.id %}">{{ item|at_handle }}</a></span>
<span class="bud-row-title"> the {{ item.active_title_display }}</span>
</li>

View File

@@ -0,0 +1,85 @@
{% extends "core/base.html" %}
{% load static %}
{% load lyric_extras %}
{% block title_text %}Billbud{% endblock title_text %}
{% block header_text %}<span>Bill</span><span>bud</span>{% endblock header_text %}
{% block content %}
{# note.js exposes window.Brief — needed by the auto-fired @mailman Brief #}
{# when this page is the destination of an invite cascade. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<div class="bud-page">
<header class="bud-page-header">
<h3>{{ bud|at_handle }} the {{ bud.active_title_display }}</h3>
<p class="bud-page-email">{{ bud.email }}</p>
</header>
<form id="id_bud_shoptalk_form"
method="POST"
action="{% url 'billboard:save_bud_shoptalk' bud.id %}"
class="bud-shoptalk-form">
{% csrf_token %}
<label for="id_shoptalk" class="bud-shoptalk-label">Shoptalk</label>
<textarea id="id_shoptalk"
name="shoptalk"
maxlength="160"
class="form-control bud-shoptalk-input"
placeholder="Your personal note about this bud…">{{ shoptalk_text }}</textarea>
</form>
{# 4-btn apparatus mirrors my_sea.html: kit (from base.html) + bud + #}
{# gear + burger. Apparatus loads bud-btn.js (defines window. #}
{# bindBudBtn — does NOT auto-bind); the inline script below opens #}
{# the panel + stubs OK to disabled per the sprint plan. #}
{% include "apps/billboard/_partials/_bud_apparatus.html" with aria_label="Bud" include_suggestions=False %}
<script>
(function () {
var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_bud_panel');
var ok = document.getElementById('id_bud_ok');
if (!btn || !panel || !ok) return;
// bud-of-bud flow isn't wired yet — stub OK as disabled w. × text.
ok.classList.add('btn-disabled');
ok.textContent = '×';
ok.setAttribute('disabled', 'disabled');
btn.addEventListener('click', function () {
var open = document.documentElement.classList.toggle('bud-open');
btn.classList.toggle('active', open);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.documentElement.classList.contains('bud-open')) {
document.documentElement.classList.remove('bud-open');
btn.classList.remove('active');
}
});
}());
</script>
{% include "apps/billboard/_partials/_bud_gear.html" %}
{# Burger fan — sea_btn_active + sea_first_draw_pending drive the #}
{# server-side .active / .glow-handoff classes (same vars my_sea + #}
{# room read). burger-btn.js auto-binds on DOMContentLoaded. #}
{% include "apps/gameboard/_partials/_burger.html" %}
</div>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
{% if pending_invite %}
<script>
(function () {
var sea = document.getElementById('id_sea_btn');
if (!sea) return;
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
// delegated fan handler (target-phase before bubble-up) so the click
// routes to the bud's spectator my-sea page. Burger then closes the
// fan as usual.
sea.addEventListener('click', function () {
if (!sea.classList.contains('active')) return;
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
});
}());
</script>
{% endif %}
{% endblock content %}

View File

@@ -11,6 +11,10 @@
{% 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>
{# Tooltip portal — populated from .bud-entry data-tt-* attrs on row #}
{# click. See my-buds-tooltip.js for the bind logic. #}
{% include "apps/billboard/_partials/_bud_tooltip.html" %}
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
{% endblock content %}
@@ -19,4 +23,5 @@
{# Brief module — needed by _bud_add_panel's OK handler so the #}
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/billboard/my-buds-tooltip.js' %}"></script>
{% endblock scripts %}

View File

@@ -41,12 +41,14 @@
{% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|at_handle }}</span>
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
<span class="post-line-text">{# adman / taxman / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
{# @mailman invite Lines carry an OK/BYE action block driven by #}
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
{# system + user Lines have no `sea_invite`, so this is skipped. #}
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
{# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #}
{# block via _invite_actions.html. Bud landing page sprint #}
{# 2026-05-27 migrates that interaction onto bud.html — the #}
{# Line's prose now embeds a post-attribution anchor (see #}
{# apps.billboard.mail.INVITE_TEMPLATE) that routes to the #}
{# owner's bud page where accept/decline/spectator live. #}
</li>
{% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li>

View File

@@ -73,6 +73,24 @@
{% block scripts %}
{% endblock scripts %}
{# Cross-page @mailman Brief surface — bud landing page sprint #}
{# 2026-05-27. Server-side context processor injects mail_ #}
{# brief_payload on every authenticated response when the user #}
{# has an unread MAIL_ACCEPTANCE Brief; banner auto-fires here #}
{# (note.js is included by pages that need Brief earlier; this #}
{# loads it if no page-block scripts pulled it in already). #}
{% if mail_brief_payload %}
{{ mail_brief_payload|json_script:"id_mail_brief_payload" }}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script>
(function () {
var el = document.getElementById('id_mail_brief_payload');
if (!el || !window.Brief || !Brief.showBanner) return;
var payload = JSON.parse(el.textContent || '{}');
if (payload && payload.title) Brief.showBanner(payload);
}());
</script>
{% endif %}
<script>
// h2 letter splitter — wrap each character of every .row .col-lg-6 h2
// word-span in its own <span> so .scss can use justify-content: