Compare commits

...

6 Commits

Author SHA1 Message Date
Disco DeDisco
c426ca69fa Note.grant_if_new admin prose: 'comes with the customary title of' → 'bestows the honorary title of'; 'additional benefits' → 'additional corporate benefits'
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Tonal shift to lean into the bureaucratic-flavour of the @adman entity. Going-forward only; existing super-schizo / super-nomad Lines in DB keep the old prose.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:44:46 -04:00
Disco DeDisco
e0ace01670 post.html attribution palette: usernames render w. @-prefix (bare emails left as-is); .post-attribution spans wrap username+title combos for the --quaUser colour key — line author col, self/shared header lines, Note.grant_if_new prose
- new lyric_extras.at_handle filter: '@{username}' if user.username, else truncate_email(user.email). Companion to display_name (which has no @-prefix). Used by post.html line author col + self/shared self lines.
  - post.html updates: line author span renders {{ line.author|at_handle }}; .post-shared-recipients chips render {{ r|at_handle }} + .post-attribution; .post-shared-self wraps "{handle} the {title}" in <span class="post-attribution">. The 'just me' / '& me' prose stays plain (only the handle+title combo is coloured).
  - Note.grant_if_new prose wraps both the @-handle (or bare email fallback) AND the title in <span class="post-attribution">. Standard format wraps the combo "{handle} the {title}" together; admin format wraps each independently since the prose splits them ("recognizes @disco for ... customary title of Schizoid Man"). Existing Lines unchanged — going-forward styling only.
  - SCSS: .post-attribution { color: rgba(var(--quaUser), 1); } scoped at .post-page so it lights up in both .post-header descendants and #id_post_table descendants. .post-line-author also switches from opacity-based dim to the same --quaUser key (drops opacity 0.75 since the colour change reads as the de-emphasis on its own).
  - 852 ITs still green — line.text inclusions ("Stargazer", "alice@test.io" etc.) still substring-match through the wrapping spans.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:42:51 -04:00
Disco DeDisco
eb0369f0b7 buds Phase 2: top-3 username|email autocomplete on #id_recipient (post share + my_buds add); implicit symmetric auto-add on share_post (sharer ↔ recipient buds graph); recipient field accepts username OR email — TDD
- billboard.views.search_buds(GET /billboard/buds/search?q=...) — top-3 prefix match against request.user.buds via Q(username__istartswith) | Q(email__istartswith). Returns {buds: [{id, username, email}]}. Privacy: only the user's own buds are searched, no leak of strangers.
  - _resolve_recipient(raw) helper resolves a free-form recipient (email if "@" present, else username, both case-insensitive). Wired into add_bud + share_post so #id_recipient accepts either form.
  - share_post implicit auto-add (per-spec): when recipient is registered + first-time-shared, both directions of buds M2M get the link — request.user.buds.add(recipient) AND recipient.buds.add(request.user). Idempotent, no auto-add on reshare/self/unregistered.
  - new bud-autocomplete.js shared module (apps/billboard/static/apps/billboard/) — bindBudAutocomplete(input, suggestionsEl, {searchUrl}). Mirrors sky.html birth-place picker: 250ms debounced fetch from MIN_CHARS=1, click-to-fill, Escape closes, click-outside closes, late-response drop. e.stopPropagation on suggestion-click so the bud-panel's outside-click handler doesn't fire and clear the input.
  - SCSS .bud-suggestions / .bud-suggestion-item mirrors .sky-suggestions but position:fixed bottom:4rem (aligned above the bud panel, with overflow:hidden on the panel forcing the dropdown to live as a sibling rather than a child). Landscape breakpoints clear the navbar/footer 4rem sidebars, 8rem at min-width 1800px.
  - both _bud_panel.html (post share) + _bud_add_panel.html (my_buds add) get the suggestions div sibling + script tags. Each panel's existing document click-outside handler now skips the suggestions container so a click inside doesn't close+clear. type="email" → type="text" since usernames are accepted; placeholder "friend@example.com or username".
  - new test classes in test_buds.py: SearchBudsViewTest (6 — prefix match, cap-3, email prefix, non-bud leakproof, empty-q, anon redirect) + SharePostImplicitAutoAddTest (4 — sharer.buds += recipient, recipient.buds += sharer, username-typed share, unregistered no-add) + AddBudViewTest.test_add_resolves_username_too. test_my_buds.py FT adds test_autocomplete_suggests_buds_by_username_prefix. test_sharing.py placeholder assertion updated to "friend@example.com or username".
  - 852 ITs (+11) + 5 my_buds 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>
2026-05-08 23:34:35 -04:00
Disco DeDisco
11ff109d1e my_posts: titles → @{handle}'s Posts + Posts by Others (view-side string build); applet-list link colour --terUser w. hover/active shifting to --ninUser + --terUser glow halo
- billboard.views.my_posts adds owner_posts_title (f"@{handle}'s Posts") + others_posts_title ("Posts by Others") to context. handle = owner.username or owner.email matches the navbar @-handle pattern.
  - my_posts.html shell invocations use the new vars instead of in-template |add: filter chains.
  - SCSS .applet-list .applet-list-entry > a: base color rgba(var(--terUser), 1), text-decoration none, font-weight bold; on :hover/:active color shifts to rgba(var(--ninUser), 1) + text-shadow 0 0 0.55rem rgba(var(--terUser), 0.7) for the lift halo. transition: text-shadow 0.15s ease.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:16:31 -04:00
Disco DeDisco
246e45e55d 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>
2026-05-08 23:08:33 -04:00
Disco DeDisco
5f6002aa70 buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
  - applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
  - billboard.views.my_buddies + add_buddy:
    • my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
    • add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
  - templates:
    • _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
    • my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
    • _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
  - SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
  - tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
  - 841 ITs (+14) + 4 my_buddies 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>
2026-05-08 22:31:42 -04:00
33 changed files with 1237 additions and 178 deletions

View File

@@ -0,0 +1,41 @@
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
User.buddies M2M (lyric/0004) lands at the same time; the applet links
to the new /billboard/my-buddies/ page where the user manages their
buddy list. "Contacts" was a placeholder name from the original
billboard scaffold.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-contacts")
except Applet.DoesNotExist:
return
applet.slug = "my-buddies"
applet.name = "My Buddies"
applet.save(update_fields=["slug", "name"])
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buddies")
except Applet.DoesNotExist:
return
applet.slug = "my-contacts"
applet.name = "Contacts"
applet.save(update_fields=["slug", "name"])
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_seed_pronouns_applet"),
]
operations = [
migrations.RunPython(forward, backward),
]

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

@@ -0,0 +1,115 @@
// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds
// add panel). Mirrors the sky.html birth-place picker pattern: debounced
// fetch on input, top-3 suggestions rendered as buttons, click-to-fill,
// Escape closes, click-outside closes. No keyboard arrow/Enter cycling.
//
// Usage:
// <div class="bud-panel-wrap">
// <input id="id_recipient" ...>
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
// </div>
// <script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
// <script>bindBudAutocomplete(
// document.getElementById('id_recipient'),
// document.getElementById('id_bud_suggestions'),
// { searchUrl: '{% url "billboard:search_buds" %}' }
// );</script>
(function () {
'use strict';
var DEBOUNCE_MS = 250;
var MIN_CHARS = 1;
function _esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : s;
return d.innerHTML;
}
window.bindBudAutocomplete = function (input, suggestions, options) {
if (!input || !suggestions || !options || !options.searchUrl) return;
var debounceTimer = null;
var lastQuery = '';
function _hide() {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
function _render(buds) {
if (!buds || !buds.length) {
_hide();
return;
}
suggestions.innerHTML = buds.map(function (b) {
// data-email + data-username so the click handler can fill the
// input with whichever the user originally typed (email if they
// started with `@`, else username).
return (
'<button type="button" class="bud-suggestion-item" ' +
'data-email="' + _esc(b.email) + '" ' +
'data-username="' + _esc(b.username) + '">' +
_esc(b.username) +
'</button>'
);
}).join('');
suggestions.hidden = false;
}
function _fetch(q) {
var url = options.searchUrl + '?q=' + encodeURIComponent(q);
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
// Drop late responses if the user has typed past this query.
if (input.value.trim() !== q) return;
_render(data.buds || []);
})
.catch(function () { _hide(); });
}
input.addEventListener('input', function () {
var q = input.value.trim();
lastQuery = q;
if (q.length < MIN_CHARS) { _hide(); return; }
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') _hide();
});
suggestions.addEventListener('click', function (e) {
var btn = e.target.closest('.bud-suggestion-item');
if (!btn) return;
// Stop propagation so the bud-panel's document-level click-
// outside handler doesn't fire and close+clear the panel —
// _hide() about to detach the target makes a `sg.contains(e.target)`
// check at the document level unreliable.
e.stopPropagation();
// Fill w. whichever form the user was typing (email vs username).
// If the input value already contains '@', prefer email; else
// prefer username. This keeps the OK-submit semantics consistent
// w. what the user intended.
var typed = input.value.trim();
input.value = typed.indexOf('@') !== -1
? btn.dataset.email
: btn.dataset.username;
_hide();
input.focus();
});
document.addEventListener('click', function (e) {
if (suggestions.hidden) return;
if (suggestions.contains(e.target)) return;
if (e.target === input) return;
_hide();
});
};
}());

View File

@@ -0,0 +1,241 @@
"""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)
def test_add_resolves_username_too_not_just_email(self):
"""Phase 2: recipient field accepts usernames as well as emails."""
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["bud"]["username"], "alice")
self.assertIn(alice, self.user.buds.all())
class SearchBudsViewTest(TestCase):
"""Top-3 prefix-match autocomplete endpoint backing #id_recipient."""
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.albert = User.objects.create(email="albert@test.io", username="albert")
self.alvin = User.objects.create(email="alvin@test.io", username="alvin")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)
def test_username_prefix_match(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
usernames = [b["username"] for b in response.json()["buds"]]
# alice, albert, alvin all start with "al" — exactly 3 (cap)
self.assertEqual(len(usernames), 3)
self.assertIn("alice", usernames)
self.assertIn("albert", usernames)
self.assertIn("alvin", usernames)
self.assertNotIn("bob", usernames)
def test_caps_at_three_results(self):
d = User.objects.create(email="alfred@test.io", username="alfred")
self.user.buds.add(d)
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
self.assertEqual(len(response.json()["buds"]), 3)
def test_email_prefix_also_matches(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
usernames = [b["username"] for b in response.json()["buds"]]
self.assertIn("bob", usernames)
def test_does_not_leak_non_buds(self):
"""Non-buds (other registered users) don't appear in suggestions."""
User.objects.create(email="stranger@test.io", username="stranger")
response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
self.assertEqual(response.json()["buds"], [])
def test_empty_q_returns_empty_list(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
self.assertEqual(response.json()["buds"], [])
def test_anon_redirects(self):
self.client.logout()
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
self.assertEqual(response.status_code, 302)
class SharePostImplicitAutoAddTest(TestCase):
"""Per-spec: when a share lands a recipient on Post.shared_with, the
sharer + recipient mutually auto-add each other to their buds lists."""
def setUp(self):
from apps.billboard.models import Post
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
self.client.force_login(self.sharer)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = Post.objects.create(owner=self.sharer)
def _share(self, recipient):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_share_adds_recipient_to_sharer_buds(self):
self._share("alice@test.io")
self.assertIn(self.alice, self.sharer.buds.all())
def test_share_adds_sharer_to_recipient_buds(self):
"""Symmetric on shared events — recipient also gets the sharer."""
self._share("alice@test.io")
self.assertIn(self.sharer, self.alice.buds.all())
def test_share_with_username_also_auto_adds(self):
self._share("alice")
self.assertIn(self.alice, self.sharer.buds.all())
self.assertIn(self.sharer, self.alice.buds.all())
def test_unregistered_recipient_does_not_auto_add(self):
"""Privacy: unregistered email doesn't touch the buds graph."""
self._share("ghost@test.io")
self.assertEqual(self.sharer.buds.count(), 0)

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-contacts", "Contacts", 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-contacts", 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-contacts") 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-contacts", "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 Contacts 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 — Contacts 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-contacts") 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,4 +18,7 @@ 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-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"),
] ]

View File

@@ -298,9 +298,12 @@ def my_posts(request, user_id):
return redirect("/") return redirect("/")
if request.user.id != owner.id: if request.user.id != owner.id:
return HttpResponseForbidden() return HttpResponseForbidden()
handle = owner.username or owner.email
return render(request, "apps/billboard/my_posts.html", { return render(request, "apps/billboard/my_posts.html", {
"owner": owner, "owner": owner,
"page_class": "page-billboard", "owner_posts_title": f"@{handle}'s Posts",
"others_posts_title": "Posts by Others",
"page_class": "page-billposts",
}) })
@@ -308,12 +311,12 @@ def share_post(request, post_id):
our_post = Post.objects.get(id=post_id) our_post = Post.objects.get(id=post_id)
is_ajax = "application/json" in request.headers.get("Accept", "") is_ajax = "application/json" in request.headers.get("Accept", "")
recipient_email = request.POST.get("recipient", "") # Recipient may be email OR username — _resolve_recipient handles both
recipient = None # (email if "@" present, else username lookup). The raw value is kept
try: # for the Line text since users see what they typed in the per-line
recipient = User.objects.get(email=recipient_email) # rendering (post-refresh + optimistic JS append).
except User.DoesNotExist: recipient_email = (request.POST.get("recipient") or "").strip()
pass recipient = _resolve_recipient(recipient_email)
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior). # Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
if recipient is not None and recipient == request.user: if recipient is not None and recipient == request.user:
@@ -329,6 +332,12 @@ def share_post(request, post_id):
if recipient is not None and not is_reshare: if recipient is not None and not is_reshare:
our_post.shared_with.add(recipient) our_post.shared_with.add(recipient)
# Implicit auto-add to the buds graph — symmetric on shared events
# (per-spec): a share-event implies a mutual social link.
# `add()` is idempotent on M2M, no need to pre-check membership.
if request.user.is_authenticated:
request.user.buds.add(recipient)
recipient.buds.add(request.user)
line = None line = None
brief = None brief = None
@@ -373,6 +382,86 @@ def share_post(request, post_id):
return redirect(our_post) return redirect(our_post)
# ── My Buds ───────────────────────────────────────────────────────────────
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
# slide-out. Privacy: when an entered email isn't a registered User, we
# 200 with {bud: null} so the response shape doesn't leak membership.
@login_required(login_url="/")
def my_buds(request):
return render(request, "apps/billboard/my_buds.html", {
"buds": request.user.buds.all(),
"page_class": "page-billbuds",
})
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
try the username lookup, so a username that happens to match an email
user's local part doesn't get coerced. Used by add_bud + share_post."""
raw = (raw or "").strip()
if not raw:
return None
if "@" in raw:
try:
return User.objects.get(email__iexact=raw)
except User.DoesNotExist:
return None
try:
return User.objects.get(username__iexact=raw)
except User.DoesNotExist:
return None
@login_required(login_url="/")
def add_bud(request):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
candidate = _resolve_recipient(request.POST.get("recipient"))
bud = None
if candidate is not None and candidate != request.user:
if candidate not in request.user.buds.all():
request.user.buds.add(candidate)
bud = {
"id": str(candidate.id),
"username": candidate.username or candidate.email,
"email": candidate.email,
}
return JsonResponse({"bud": bud})
@login_required(login_url="/")
def search_buds(request):
"""Top-3 prefix-match autocomplete pool for #id_recipient inputs.
Pulls only from request.user.buds — buds that haven't been added yet
don't appear in the autocomplete (privacy-by-default; new buds enter
the list via explicit add or implicit auto-add on share/invite).
Matches case-insensitive on either username or email prefix."""
from django.db.models import Q
q = (request.GET.get("q") or "").strip()
if not q:
return JsonResponse({"buds": []})
matches = (
request.user.buds
.filter(Q(username__istartswith=q) | Q(email__istartswith=q))
.order_by("username", "email")[:3]
)
return JsonResponse({"buds": [
{
"id": str(b.id),
"username": b.username or b.email,
"email": b.email,
}
for b in matches
]})
@login_required(login_url="/") @login_required(login_url="/")
def save_scroll_position(request, room_id): def save_scroll_position(request, room_id):
if request.method != "POST": if request.method != "POST":

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

@@ -293,21 +293,31 @@ class Note(models.Model):
post.title = NOTE_UNLOCK_POST_TITLE post.title = NOTE_UNLOCK_POST_TITLE
post.save(update_fields=["title"]) post.save(update_fields=["title"])
username = user.username or user.email # Bare-email fallback when user.username is None (no `@` prefix —
# the address already carries one). When username is set, use the
# `@handle` form. Both wrapped in .post-attribution so the CSS
# palette key (--quaUser) lights up the username + title combo.
handle = f"@{user.username}" if user.username else user.email
note_anchor = ( note_anchor = (
f'<a class="note-ref" href="/billboard/my-notes/">' f'<a class="note-ref" href="/billboard/my-notes/">'
f'{note.display_name}</a>' f'{note.display_name}</a>'
) )
attr_handle = f'<span class="post-attribution">{handle}</span>'
attr_title = f'<span class="post-attribution">{note.display_title}</span>'
if slug in _ADMIN_NOTE_SLUGS: if slug in _ADMIN_NOTE_SLUGS:
line_text = ( line_text = (
f"The administration recognizes {username} for {note_anchor}, " f"The administration recognizes {attr_handle} for {note_anchor}, "
f"which comes with the customary title of {note.display_title}. " f"which bestows the honorary title of {attr_title}. "
"This does not entail any additional benefits." "This does not entail any additional corporate benefits."
) )
else: else:
attr_combo = (
f'<span class="post-attribution">{handle} '
f'the {note.display_title}</span>'
)
line_text = ( line_text = (
f"Look!—new Note unlocked. {note_anchor} " f"Look!—new Note unlocked. {note_anchor} "
f"recognizes {username} the {note.display_title}." f"recognizes {attr_combo}."
) )
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded # Lazy get-or-create: TransactionTestCase flushes the migration-seeded

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lyric", "0003_seed_adman"),
]
operations = [
migrations.AddField(
model_name="user",
name="buddies",
field=models.ManyToManyField(
blank=True,
related_name="added_as_buddy",
to="lyric.user",
),
),
]

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,6 +113,14 @@ 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.buds.all()` = people I've explicitly
# added (or implicitly via post-share / game-invite, which auto-adds
# the recipient to the inviter's buds list). `user.added_as_bud` is
# the inverse (people who have me in their buds list); useful for
# the future "bud changed username" snapshot-accept flow.
buds = models.ManyToManyField(
"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,
on_delete=models.SET_NULL, related_name="+", on_delete=models.SET_NULL, related_name="+",

View File

@@ -48,3 +48,16 @@ def display_name(user):
if user.username: if user.username:
return user.username return user.username
return truncate_email(user.email) return truncate_email(user.email)
@register.filter
def at_handle(user):
"""`@username` when the user has set one; falls back to the truncated
email otherwise (no `@` prefix on bare emails since the address itself
already carries the `@`). Used in post.html to colour usernames in the
--quaUser palette key while leaving emails as-is."""
if user is None:
return ""
if user.username:
return f"@{user.username}"
return truncate_email(user.email)

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-contacts", "Contacts", 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 Contacts, 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-contacts", "Contacts", 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_contacts") 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-contacts']" 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_contacts"), 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-contacts']" 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_contacts"), 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_contacts"), 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-contacts", "Contacts", 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

@@ -0,0 +1,100 @@
"""FT for the My Buds page — bud btn + slide-out add flow.
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
implicit auto-add on post-share / gate-invite.
"""
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from apps.lyric.models import User
from .base import FunctionalTest
class MyBudsPageTest(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.create_pre_authenticated_session("me@test.io")
def test_renders_existing_buds(self):
"""Pre-existing buds show up as entries on first render."""
self.gamer.buds.add(self.alice)
self.browser.get(self.live_server_url + "/billboard/my-buds/")
entry = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".bud-entry .bud-name")
)
self.assertEqual(entry.text, "alice")
def test_empty_state_when_no_buds(self):
self.browser.get(self.live_server_url + "/billboard/my-buds/")
empty = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".applet-list-entry--empty")
)
self.assertIn("No buds yet", empty.text)
def test_add_bud_via_bud_btn_appends_entry(self):
self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
ok.click()
# New entry appears w. alice's username (not the bare email)
self.wait_for(lambda: self.assertEqual(
self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name"
).text,
"alice",
))
# Server-side persisted
self.wait_for(lambda: self.assertIn(
self.alice, list(self.gamer.buds.all())
))
def test_autocomplete_suggests_buds_by_username_prefix(self):
"""Phase 2: typing in #id_recipient pulls top-3 prefix matches from
request.user.buds and renders them as .bud-suggestion-item buttons.
Click → input.value fills with the bud's username (or email if the
user typed an `@` already)."""
self.gamer.buds.add(self.alice)
bob = User.objects.create(email="bob@test.io", username="bob")
self.gamer.buds.add(bob)
self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("al")
suggestions = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_suggestions .bud-suggestion-item"
))
self.assertEqual(suggestions.text.strip(), "alice")
suggestions.click()
self.wait_for(lambda: self.assertEqual(
recipient.get_attribute("value"), "alice"
))
def test_add_unregistered_email_is_silent_noop(self):
self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("ghost@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
ok.click()
# Wait for the panel close (a positive signal the request landed)
self.wait_for(lambda: self.assertNotIn(
"active", btn.get_attribute("class")
))
# No bud entries (the empty-state row has its own --empty class)
entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
self.assertEqual(len(entries), 0)

View File

@@ -41,7 +41,7 @@ class SharingTest(FunctionalTest):
share_box = post_page.get_share_box() share_box = post_page.get_share_box()
self.assertEqual( self.assertEqual(
share_box.get_attribute("placeholder"), share_box.get_attribute("placeholder"),
"friend@example.com", "friend@example.com or username",
) )
post_page.share_post_with("alice@test.io") post_page.share_post_with("alice@test.io")

View File

@@ -35,13 +35,17 @@
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-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-billbuds,
body.page-billposts {
overflow: hidden; overflow: hidden;
.container { .container {
@@ -126,6 +130,14 @@ body.page-billpost {
padding: 0.75rem; padding: 0.75rem;
gap: 0.5rem; gap: 0.5rem;
// Username + title attribution spans — line author column, self/shared
// header lines, server-rendered grant prose. --quaUser palette key
// unifies them across the page; placed at .post-page scope so it
// applies in BOTH .post-header and #id_post_table descendants.
.post-attribution {
color: rgba(var(--quaUser), 1);
}
.post-header { .post-header {
flex-shrink: 0; flex-shrink: 0;
@@ -165,7 +177,7 @@ body.page-billpost {
.post-line-author { .post-line-author {
font-weight: bold; font-weight: bold;
opacity: 0.75; color: rgba(var(--quaUser), 1);
white-space: nowrap; white-space: nowrap;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -215,6 +227,72 @@ body.page-billpost {
} }
} }
// ── Applet-list page (Billbuds, Billposts) ───────────────────────────────
// Shared shell for pages built around _applet-list-shell.html — vertical
// 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).
.applet-list-page {
@extend %billboard-page-base;
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.75rem;
.applet-scroll {
@extend %applet-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.applet-list {
list-style: none;
margin: 0;
padding: 0 0.75rem 0 0;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.applet-list-entry {
padding: 0.4rem 0;
.bud-name { font-weight: bold; opacity: 0.85; }
&--empty { opacity: 0.6; font-style: italic; }
a {
color: rgba(var(--terUser), 1);
text-decoration: none;
font-weight: bold;
transition: text-shadow 0.15s ease;
&:hover,
&:active {
color: rgba(var(--ninUser), 1);
text-shadow: 0 0 0.55rem rgba(var(--terUser), 0.7);
}
}
}
.applet-list-buffer {
flex-shrink: 0;
height: 0.5rem;
}
}
// Side-by-side in landscape; stacked in portrait (default).
&--two-up {
@media (orientation: landscape) {
flex-direction: row;
.applet-scroll { flex: 1; }
}
}
}
// ── Billboard applet placement ───────────────────────────────────────────── // ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Notes stacked. // Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent Scroll spans full height. // Right column (8-wide): Most Recent Scroll spans full height.
@@ -222,13 +300,13 @@ body.page-billpost {
#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_contacts { 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_contacts, #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,61 @@ 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;
} }
// Bud autocomplete suggestions (mirror of sky-place birth picker)
// Sibling of #id_bud_panel (which has overflow:hidden for the scaleX
// slide animation, so the suggestions can't be a child or they'd clip).
// Position-fixed above the panel; same left/right inset as the panel
// at each breakpoint so the dropdown lines up.
.bud-suggestions {
position: fixed;
bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem)
left: 1.5rem;
right: 1.5rem;
z-index: 320; // above the panel itself
background: rgba(var(--priUser), 1);
border: 0.1rem solid rgba(var(--terUser), 0.3);
border-radius: 0.3rem;
overflow-y: auto;
max-height: 10rem;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
@media (orientation: landscape) {
left: calc(4rem + 0.5rem);
right: calc(4rem + 0.5rem);
}
@media (orientation: landscape) and (min-width: 1800px) {
left: calc(8rem + 0.5rem);
right: calc(8rem + 0.5rem);
}
}
.bud-suggestion-item {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
text-align: left;
background: none;
border: none;
border-bottom: 0.05rem solid rgba(var(--terUser), 0.1);
font-size: 0.85rem;
color: rgba(var(--ninUser), 0.85);
cursor: pointer;
line-height: 1.35;
&:last-child { border-bottom: none; }
&:hover, &:focus {
background: rgba(var(--terUser), 0.12);
color: rgba(var(--ninUser), 1);
outline: 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_contacts" 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>Contacts</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

@@ -0,0 +1,143 @@
{% load static %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _bud_add_panel.html — bottom-left handshake btn + slide-out add- #}
{# bud field. Mirrors _bud_panel.html (post-share) but POSTs to #}
{# add_bud and appends to #id_buds_list instead of #id_post_table. #}
{# Included by my_buds.html only. #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_bud_btn" type="button" aria-label="Add a bud">
<i class="fa-solid fa-handshake"></i>
</button>
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
<input id="id_recipient"
name="recipient"
type="text"
placeholder="friend@example.com or username"
autocomplete="off">
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div>
{# Autocomplete suggestions list — sibling of #id_bud_panel because the #}
{# panel has overflow:hidden for its scaleX slide animation. #}
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<script>
(function () {
'use strict';
var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_bud_ok');
var html = document.documentElement;
if (!btn || !panel || !input || !ok) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _open() {
html.classList.add('bud-open');
btn.classList.add('active');
setTimeout(function () { input.focus(); }, 60);
}
function _close(opts) {
opts = opts || {};
html.classList.remove('bud-open');
btn.classList.remove('active');
if (opts.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('bud-open')) {
_close();
} else {
_open();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
});
document.addEventListener('click', function (e) {
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Suggestions live outside the panel (panel has overflow:hidden
// for its scaleX slide); a click inside them must NOT close+clear.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});
function _appendBudEntry(bud) {
var list = document.getElementById('id_buds_list');
if (!list || !bud) return;
// Skip if already in DOM (server-side dedup ensures M2M idempotence;
// this guards a fast double-click that races the post-add refresh).
if (list.querySelector('[data-bud-id="' + bud.id + '"]')) return;
// Drop the empty-state row if present
var empty = list.querySelector('.bud-entry--empty');
if (empty) empty.remove();
var li = document.createElement('li');
li.className = 'bud-entry';
li.dataset.budId = bud.id;
var name = document.createElement('span');
name.className = 'bud-name';
name.textContent = bud.username;
li.appendChild(name);
var buffer = list.querySelector('.bud-entry-buffer');
if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li);
}
ok.addEventListener('click', function () {
var email = input.value.trim();
if (!email) return;
var fd = new FormData();
fd.set('recipient', email);
fetch(panel.dataset.addUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data.bud) _appendBudEntry(data.bud);
_close({ clear: true });
})
.catch(function () {
// Privacy-safe response shape — even an unregistered email is
// a 200 w. {bud: null}. Network/5xx land here; just close.
});
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
}());
</script>

View File

@@ -1,38 +1,51 @@
{% 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"
name="recipient" name="recipient"
type="email" type="text"
placeholder="friend@example.com" placeholder="friend@example.com or username"
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>
{# Autocomplete suggestions — sibling of #id_bud_panel because the panel #}
{# has overflow:hidden for its scaleX slide animation. #}
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<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 +55,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 +63,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,14 +78,19 @@
// 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;
// Autocomplete suggestions sit outside the panel (panel overflow
// is hidden for the scaleX slide). A click inside them must NOT
// close+clear the panel.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close(); _close();
}); });
@@ -107,7 +125,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

@@ -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_posts_title 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=others_posts_title 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 %}
@@ -16,10 +16,10 @@
<h3 class="post-title">{{ post.title }}</h3> <h3 class="post-title">{{ post.title }}</h3>
{% with recipients=post.shared_with.all %} {% with recipients=post.shared_with.all %}
{% if recipients %} {% if recipients %}
<p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient">{{ r|display_name }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p> <p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p> <p class="post-shared-self">&amp; me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% else %} {% else %}
<p class="post-shared-self">just me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p> <p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</header> </header>
@@ -27,7 +27,7 @@
<ul id="id_post_table" class="post-lines"> <ul id="id_post_table" class="post-lines">
{% for line in post.lines.all %} {% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}"> <li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|display_name }}</span> <span class="post-line-author">{{ line.author|at_handle }}</span>
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span> <span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span>
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time> <time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time>
</li> </li>
@@ -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 %}