Compare commits

...

7 Commits

Author SHA1 Message Date
Disco DeDisco
22d0507c3f post.html header prose branches on viewer-vs-owner: invitees see "shared with me, @viewer the {title}" + "created by @owner the {title}" instead of the owner-centric "just me / shared between" lines; owner view unchanged — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- billboard.views.view_post adds viewer_is_owner + other_recipients context vars. is_real_invitee = (auth AND post has owner AND viewer != owner). Anon viewers + ownerless-post legacy path fall through to owner-style rendering (which renders empty gracefully via the at_handle / display_name AnonymousUser guards).
  - other_recipients = post.shared_with.exclude(viewer) when invitee; .all() otherwise.
  - post.html .post-header branches:
    • viewer_is_owner: existing prose ("just me, @owner …" / "shared between {recipients} & me, @owner …").
    • sole invitee: "shared with me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
    • multi invitee: "shared with {other_recipients}" + "& me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
  - lyric_extras at_handle + display_name: guard against AnonymousUser (no .email attribute) — return "" rather than crash. Preserves the Percival ch. 18 anon-views-ownerless-post path.
  - 12 new ITs in test_post_invitee_view (context vars: viewer_is_owner, other_recipients exclude/include; template prose: sole + multi invitee phrasing, owner unchanged).
  - 878 IT regression + 8 post-html FT regression green (1 Marionette flake on multi-run that passes in isolation).

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-09 01:14:11 -04:00
Disco DeDisco
419e022140 gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD
- Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set.
  - epic.invite_gamer view refactor:
    • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat).
    • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username).
    • RoomInvite stores the resolved User's email (or raw input if unregistered).
    • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User.
    • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null.
    • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before.
    • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch.
  - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed.
  - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE.
  - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner.
  - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add).
  - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable).
  - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references).
  - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper 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-09 00:59:54 -04:00
Disco DeDisco
4010e452a6 recentered copyright on landscape 2026-05-09 00:38:51 -04:00
Disco DeDisco
72fefe2fc7 landscape footer rearrange: #id_bud_btn moves to top of right sidebar (upper-right corner of footer); ©2026 Dis Co. text becomes a single-line vertical strip at the very right edge of the viewport (mirror of portrait's "after the icons" position); bud-panel + bud-suggestions follow the bud-btn to the top w. transform-origin: right center
- _bud.scss #id_bud_btn landscape: left: auto + right: calc((var(--sidebar-w) - 3rem) / 2) + top: 0.5rem + bottom: auto. Centred horizontally in the right sidebar; tucked at the top.
  - _bud.scss #id_bud_panel landscape: top: 0.5rem; bottom: auto; transform-origin: right center. Slides leftward from the bud-btn along the top edge of the viewport (instead of bottom).
  - _bud.scss .bud-suggestions landscape: top: 4rem; bottom: auto; box-shadow flipped from upward to downward — autocomplete dropdown now opens BELOW the panel (since panel is at top).
  - _base.scss .footer-container landscape: writing-mode: vertical-rl + transform: rotate(180deg) for a bottom-to-top single-line read; right: 0.125rem (tight against the viewport edge); bottom: 0.5rem; line-height: 1; <br> { display: none } collapses the two-line "©2026 / Dis Co." into one line "©2026 Dis Co.". Tucks into the empty 0.875rem gutter between the viewport edge and the centred icon column — no overlap w. kit-btn / gear-btn at the bottom.
  - 21 bud FTs green (portrait position contract intact: bottom-left, the landscape move is orientation-scoped).

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-09 00:35:26 -04:00
Disco DeDisco
47871b5b4a align #id_kit_btn / #id_gear_btn / #id_bud_btn under the unified centre formula — drop the legacy right: 2.5rem ≥1800px override on kit-btn that left it outboard of gear-btn after the rem-fluid sidebar refactor
- _game-kit.scss #id_kit_btn landscape rule now uses `right: calc((var(--sidebar-w) - 3rem) / 2)` — same formula as gear-btn (_applets.scss) and bud-btn (_bud.scss). All three 3rem-wide circular btns now share the same horizontal-centre math against the fluid sidebar.
  - Drops `@media (orientation: landscape) and (min-width: 1800px) { right: 2.5rem }` which was a leftover from the old doubled-8rem-sidebar regime; the rem clamp ceiling now caps the sidebar without per-breakpoint overrides.

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-09 00:23:53 -04:00
Disco DeDisco
ad9f7b43ed h2 padding-inline boundary between the two spans (BILL | BOARD natural gap) + landscape inherits portrait flex split (45/55 vertical, with padding-inline-end on first span = visual break between rotated words)
- portrait h2 spans get padding-inline-end / padding-inline-start (0.4em each) at the 45/55 boundary; box-sizing: border-box keeps the flex basis honest. Solves the "B I L L B O A R D" run-together where the L of BILL touches the B of BOARD.
  - landscape h2 drops the `display: block` override + the > span resets that nuked text-align: justify and flex. Now inherits the portrait flex 45/55 + per-span justify + padding-inline. With writing-mode: vertical-rl, the flex axis runs vertically (45% bottom for BILL post-rotate, 55% top for POST/BOARD/etc.); padding-inline-end resolves to the bottom edge of the first span = natural gap between the two rotated words.
  - Explicit h2 height: 80vh in landscape so the flex 45/55 percentages have a defined basis to resolve against (block height isn't auto-derived in writing-mode: vertical-rl).
  - 8 layout/navbar FTs still green; assertions are categorical (position: fixed, etc.) not exact-px.

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-09 00:18:57 -04:00
Disco DeDisco
3ab60c67b6 fluid root rem + landscape aperture: html font-size = clamp(14px, 2.4vmin, 22px) so 1rem scales w. viewport (rotation-invariant via vmin); --sidebar-w + --h2-col-w CSS vars unify navbar/footer/h2 sizing; container margin-left = sidebar + h2-col-w in landscape so applets clip cleanly under the rotated wordmark; h2 markup splits into two spans (45/55 horizontal title); drop the disparate min-height font-size jumps + 1800px sidebar-doubling overrides
- html { font-size: clamp(14px, 2.4vmin, 22px) } — single sliding scale; everything in rem (sidebar widths, h2 font-size, paddings) scales together. Phone rotation swaps width/height but vmin stays the same → 1rem stays the same → navbar/footer/h2 hold their size between portrait + landscape.
  - :root --sidebar-w: 5rem (replaces the locally-scoped $sidebar-w SCSS var that lived inside @media blocks); --h2-col-w: 3rem for the rotated wordmark column in landscape. var(--sidebar-w) + var(--h2-col-w) are the only knobs that move the layout.
  - Landscape container: margin-left = calc(var(--sidebar-w) + var(--h2-col-w)); margin-right = var(--sidebar-w). Applets are now clipped INSIDE the h2 column, so the rotated "BILLPOST" / "DASHBOARD" wordmark never has content bleeding behind it (the original complaint).
  - h2 markup refactor across 13 templates: <span>BILL</span><span>POST</span> instead of <span>BILL</span>POST. Portrait styling: display: flex; first span flex 0 0 45% + --quaUser colour; second span flex 0 0 55% + --secUser inherited. Per-span text-align: justify + text-justify: inter-character keeps the inter-letter spacing within each span. Landscape resets the flex (single rotated wordmark, not split).
  - Drop the four h2 font-size jumps (min-height: 400/500/800px) — single font-size: 3rem now scales fluidly via root rem. Drop the @media (orientation: landscape) and (max-width: 1100px) h1 override (rem-fluid handles cramped widths). Drop the entire @media (orientation: landscape) and (min-width: 1800px) sidebar-doubling block in _base.scss / _applets.scss / _bud.scss — the rem clamp ceiling already caps the size.
  - _bud.scss + _applets.scss: bud-btn / bud-panel / bud-suggestions / gear-btn / applet menus all switch to var(--sidebar-w)-based positioning; landscape rules are single (no per-breakpoint duplication).
  - Per-spec tradeoff: non-.btn-primary buttons (BYE / NVM / OK / kit-btn / etc.) inherit rem-fluid like everything else and will scale slightly w. viewport. User explicitly OK'd this — they don't need to stay px-fixed.
  - 852 ITs + 24 layout/navbar/bud FTs green; existing geometry assertions are relative or categorical (not exact-px) so the rem clamp doesn't surface failures at the 800x1200 FT viewport.

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-09 00:14:14 -04:00
29 changed files with 894 additions and 211 deletions

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-05-09 04:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0006_alter_line_options'),
('epic', '0008_blades_reversal_fickle'),
]
operations = [
migrations.AddField(
model_name='brief',
name='room',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'),
),
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='brief',
name='post',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'),
),
]

View File

@@ -94,10 +94,12 @@ class Brief(models.Model):
KIND_NOTE_UNLOCK = "note_unlock" KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post" KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite" KIND_SHARE_INVITE = "share_invite"
KIND_GAME_INVITE = "game_invite"
KIND_CHOICES = [ KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"), (KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"), (KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"), (KIND_SHARE_INVITE, "Share invite"),
(KIND_GAME_INVITE, "Game invite"),
] ]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -106,10 +108,25 @@ class Brief(models.Model):
related_name="briefs", related_name="briefs",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
# instead of a Post (the gatekeeper invite confirmation has no post
# to navigate to). Post FKs only set for note_unlock / user_post /
# share_invite kinds.
post = models.ForeignKey( post = models.ForeignKey(
Post, Post,
related_name="briefs", related_name="briefs",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
)
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
# the gatekeeper page for that room.
room = models.ForeignKey(
"epic.Room",
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
) )
# Line is nullable because a share_invite-style Brief can race ahead of its # Line is nullable because a share_invite-style Brief can race ahead of its
# async-appended Line write; the post FK alone is enough to navigate. # async-appended Line write; the post FK alone is enough to navigate.
@@ -142,16 +159,23 @@ class Brief(models.Model):
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind """Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
carries a square_url pointing at /billboard/my-notes/ so the carries a square_url pointing at /billboard/my-notes/ so the
thumbnail-square inside the banner jumps direct to the user's Note thumbnail-square inside the banner jumps direct to the user's Note
collection — other kinds get an empty square_url.""" collection. GAME_INVITE kind has no Post — the FYI link navigates
to the gatekeeper page for the brief's Room instead."""
square_url = "" square_url = ""
if self.kind == self.KIND_NOTE_UNLOCK: if self.kind == self.KIND_NOTE_UNLOCK:
square_url = reverse("billboard:my_notes") square_url = reverse("billboard:my_notes")
if self.post_id:
post_url = self.post.get_absolute_url()
elif self.room_id:
post_url = reverse("epic:gatekeeper", args=[self.room_id])
else:
post_url = ""
return { return {
"id": str(self.id), "id": str(self.id),
"kind": self.kind, "kind": self.kind,
"title": self.title, "title": self.title,
"line_text": self.line.text if self.line else "", "line_text": self.line.text if self.line else "",
"post_url": self.post.get_absolute_url(), "post_url": post_url,
"square_url": square_url, "square_url": square_url,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
} }

View File

@@ -40,14 +40,14 @@ class BriefModelTest(TestCase):
b = Brief.objects.create(owner=self.user, post=self.post) b = Brief.objects.create(owner=self.user, post=self.post)
self.assertIsNone(b.line) self.assertIsNone(b.line)
def test_brief_owner_post_required(self): def test_brief_owner_required(self):
"""Brief without owner OR post is invalid; both are the load-bearing """Brief without owner is invalid (load-bearing for "whose
FKs (owner = whose attention; post = where FYI navigates).""" attention"). Post used to be required too, but became nullable
when GAME_INVITE briefs landed (those use Brief.room instead of
Brief.post). The view layer enforces "post XOR room" per kind."""
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
with transaction.atomic(), self.assertRaises(IntegrityError): with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(post=self.post, line=self.line) Brief.objects.create(post=self.post, line=self.line)
with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(owner=self.user, line=self.line)
def test_brief_carries_title(self): def test_brief_carries_title(self):
b = Brief.objects.create( b = Brief.objects.create(

View File

@@ -0,0 +1,152 @@
"""ITs for post.html invitee-vs-owner header rendering.
The "just me, @owner the {title}" / "shared between … & me, @owner …" lines
were owner-centric (the legacy phrasing assumed the viewer is the post
creator). For an invitee (a user in post.shared_with), that prose is
confusing. This view branches the .post-header block:
• Owner viewing → unchanged existing prose.
• Invitee viewing (sole) → "shared with me, @viewer the {title}" +
"created by @owner the {owner_title}".
• Invitee viewing (multi) → "shared with {other_recipients ...}" +
"& me, @viewer the {title}" +
"created by @owner the {owner_title}".
The view layer adds `viewer_is_owner` + `other_recipients` to the
context; template branches on those.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Line, Post
from apps.lyric.models import User
class PostInviteeViewContextTest(TestCase):
"""Context vars: viewer_is_owner + other_recipients."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.post = Post.objects.create(owner=self.owner, title="Coolio")
Line.objects.create(post=self.post, text="seed", author=self.owner)
def test_owner_viewing_sets_viewer_is_owner_true(self):
self.client.force_login(self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertTrue(response.context["viewer_is_owner"])
def test_invitee_viewing_sets_viewer_is_owner_false(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertFalse(response.context["viewer_is_owner"])
def test_other_recipients_excludes_viewer(self):
"""For an invitee, other_recipients = shared_with minus self."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
others = list(response.context["other_recipients"])
self.assertIn(self.bob, others)
self.assertNotIn(self.alice, others)
def test_other_recipients_empty_for_sole_invitee(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertEqual(list(response.context["other_recipients"]), [])
def test_other_recipients_for_owner_is_full_shared_with(self):
"""Owner viewing: other_recipients includes everyone (no self exclusion)."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
others = list(response.context["other_recipients"])
self.assertIn(self.alice, others)
self.assertIn(self.bob, others)
class PostInviteeViewTemplateTest(TestCase):
"""Template prose: invitee branch shows "shared with me, …" /
"created by @owner …" — does NOT show the owner-centric "just me" or
"shared between"."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.post = Post.objects.create(owner=self.owner, title="Coolio")
Line.objects.create(post=self.post, text="seed", author=self.owner)
def test_sole_invitee_sees_shared_with_me(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
body = response.content.decode()
self.assertIn("shared with me", body)
self.assertIn("@alice", body)
def test_sole_invitee_does_not_see_just_me_or_shared_between(self):
"""Scope to .post-header — the bud-panel JS includes 'just me,' as
a regex literal in inline script, so a body-wide string match
false-positives."""
import lxml.html
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
tree = lxml.html.fromstring(body)
header_text = tree.cssselect(".post-header")[0].text_content()
self.assertNotIn("just me,", header_text)
self.assertNotIn("shared between", header_text)
def test_invitee_sees_created_by_owner(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("created by", body)
self.assertIn("@owner", body)
def test_multi_invitee_sees_shared_with_others_then_amp_me(self):
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("shared with", body)
self.assertIn("@bob", body)
self.assertIn("&amp; me", body)
self.assertIn("@alice", body)
def test_multi_invitee_does_not_see_self_in_recipients_line(self):
"""The recipients line lists OTHER invitees, not self."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
# Coarse check: "shared with @bob" appears w/o @alice in same line
# (since alice's "@alice" is on the "& me" line below). The full
# body contains both, but the .post-shared-recipients line should
# only list other_recipients (i.e., bob, not alice).
# Use a narrower lxml-style assertion.
import lxml.html
tree = lxml.html.fromstring(body)
recipients_p = tree.cssselect(".post-shared-recipients")
self.assertEqual(len(recipients_p), 1)
rec_text = recipients_p[0].text_content()
self.assertIn("@bob", rec_text)
self.assertNotIn("@alice", rec_text)
def test_owner_view_unchanged_when_recipients_present(self):
"""Owner sees 'shared between' (old behavior)."""
self.post.shared_with.add(self.alice)
self.client.force_login(self.owner)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("shared between", body)
self.assertIn("&amp; me", body)
self.assertNotIn("created by", body)
def test_owner_view_just_me_when_no_recipients(self):
"""Owner with no recipients: 'just me, …' (old behavior)."""
self.client.force_login(self.owner)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("just me,", body)
self.assertNotIn("created by", body)

View File

@@ -285,9 +285,31 @@ def view_post(request, post_id):
Brief.objects.filter( Brief.objects.filter(
owner=request.user, post=our_post, is_unread=True, owner=request.user, post=our_post, is_unread=True,
).update(is_unread=False) ).update(is_unread=False)
# Header-prose branching: post.html shows different self/shared lines
# depending on whether the viewer IS the owner. The invitee branch
# ("shared with me, @viewer …" + "created by @owner …") only kicks
# in when (a) the viewer is authenticated AND (b) the post has an
# owner AND (c) the viewer is NOT that owner. Ownerless posts and
# anonymous viewers fall through to the owner-style rendering (which
# handles missing data gracefully via the at_handle/display_name
# filter guards).
is_real_invitee = (
request.user.is_authenticated
and our_post.owner is not None
and request.user != our_post.owner
)
viewer_is_owner = not is_real_invitee
if is_real_invitee:
other_recipients = our_post.shared_with.exclude(pk=request.user.pk)
else:
other_recipients = our_post.shared_with.all()
return render(request, "apps/billboard/post.html", { return render(request, "apps/billboard/post.html", {
"post": our_post, "post": our_post,
"form": form, "form": form,
"viewer_is_owner": viewer_is_owner,
"other_recipients": other_recipients,
"page_class": "page-billpost", "page_class": "page-billpost",
}) })

View File

@@ -0,0 +1,143 @@
"""ITs for the gatekeeper invite_gamer view post-bud-btn refactor.
The legacy form-submit path (POST `invitee_email` + 302 redirect) still
works for any pre-existing caller; the new bud-btn slide-out POSTs
`recipient` (email OR username) w. Accept: application/json and gets
back {brief, recipient_display}. On registered recipients we auto-add
both directions of the buds graph (mirrors share_post per the Phase 2
spec) and spawn a Brief w. kind=GAME_INVITE pointing at the room.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief
from apps.epic.models import Room, RoomInvite
from apps.lyric.models import User
class InviteGamerAjaxTest(TestCase):
"""Bud-btn flow: POST /room/<id>/gate/invite w. Accept: application/json."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.client.force_login(self.owner)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
def _invite(self, recipient):
return self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_ajax_invite_returns_brief_payload(self):
response = self._invite("alice@test.io")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("brief", body)
self.assertIn("recipient_display", body)
self.assertIsNotNone(body["brief"])
def test_ajax_invite_creates_room_invite(self):
self._invite("alice@test.io")
self.assertTrue(RoomInvite.objects.filter(
room=self.room, inviter=self.owner, invitee_email="alice@test.io",
).exists())
def test_ajax_invite_resolves_username_to_email(self):
"""Username-typed recipient stores the resolved User's email."""
self._invite("alice")
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
def test_ajax_invite_creates_brief_with_game_invite_kind_and_room_fk(self):
self._invite("alice@test.io")
brief = Brief.objects.get(owner=self.owner)
self.assertEqual(brief.kind, Brief.KIND_GAME_INVITE)
self.assertEqual(brief.room, self.room)
self.assertIsNone(brief.post)
self.assertTrue(brief.is_unread)
def test_ajax_invite_brief_banner_dict_links_to_room_gatekeeper(self):
body = self._invite("alice@test.io").json()
self.assertEqual(
body["brief"]["post_url"],
reverse("epic:gatekeeper", args=[self.room.id]),
)
def test_ajax_invite_auto_adds_recipient_to_inviter_buds(self):
self._invite("alice@test.io")
self.assertIn(self.alice, self.owner.buds.all())
def test_ajax_invite_auto_adds_inviter_to_recipient_buds_symmetric(self):
"""Per Phase 2 spec: shared events imply mutual buds graph link."""
self._invite("alice@test.io")
self.assertIn(self.owner, self.alice.buds.all())
def test_ajax_invite_unregistered_email_creates_invite_no_buds_add(self):
"""Privacy + correctness: unregistered recipient still gets a
RoomInvite (so they can accept after registration), but the
inviter's buds list isn't touched (we don't auto-add a non-User)."""
response = self._invite("ghost@test.io")
self.assertEqual(response.status_code, 200)
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="ghost@test.io",
).exists())
self.assertEqual(self.owner.buds.count(), 0)
def test_ajax_invite_self_is_silent_noop(self):
"""Inviting yourself: brief=null, no RoomInvite, no buds touch."""
response = self._invite("owner@test.io")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["brief"])
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
def test_ajax_invite_blank_recipient_is_silent_noop(self):
response = self._invite("")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["brief"])
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
def test_ajax_invite_recipient_display_is_username_when_registered(self):
body = self._invite("alice@test.io").json()
self.assertEqual(body["recipient_display"], "alice")
def test_ajax_invite_recipient_display_is_null_when_unregistered(self):
body = self._invite("ghost@test.io").json()
self.assertIsNone(body["recipient_display"])
class InviteGamerLegacyFormTest(TestCase):
"""Form-submit path (no Accept: application/json) preserved — older
callers still get a 302 redirect to the gatekeeper. The legacy
`invitee_email` field name is also still accepted for full backwards
compat, even though the new bud-btn uses `recipient`."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")
self.client.force_login(self.owner)
self.room = Room.objects.create(name="Test", owner=self.owner)
def test_form_submit_with_invitee_email_redirects(self):
response = self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"invitee_email": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"],
reverse("epic:gatekeeper", args=[self.room.id]),
)
def test_form_submit_with_recipient_field_also_works(self):
"""The bud-btn field name (recipient) also works on form submit."""
response = self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())

View File

@@ -656,16 +656,81 @@ def pick_roles(request, room_id):
@login_required @login_required
def invite_gamer(request, room_id): def invite_gamer(request, room_id):
if request.method == "POST": """Gatekeeper invite flow. Backwards-compatible w. the legacy
room = Room.objects.get(id=room_id) `invitee_email` form-submit (still POSTs from any old caller); also
email = request.POST.get("invitee_email", "").strip() serves the new bud-btn slide-out which sends `recipient` (email OR
if email: username) + Accept: application/json. Bud-btn flow:
RoomInvite.objects.get_or_create( • Resolves recipient via _resolve_recipient (registered → User; else None).
room=room, • Stores RoomInvite using the resolved email (or raw input if unregistered).
inviter=request.user, • Auto-adds inviter ↔ recipient to each others' buds (symmetric, per
invitee_email=email, share_post precedent — registered recipients only).
defaults={"status": RoomInvite.PENDING} • Spawns a Brief w. kind=GAME_INVITE + room=room (post=null).
) • Returns JSON {brief, recipient_display} when Accept matches; else
redirects to gatekeeper as before."""
if request.method != "POST":
return redirect("epic:gatekeeper", room_id=room_id)
from apps.billboard.models import Brief
from apps.billboard.views import _resolve_recipient
room = Room.objects.get(id=room_id)
is_ajax = "application/json" in request.headers.get("Accept", "")
# New bud-btn field name is `recipient`; legacy form uses `invitee_email`.
raw = (
request.POST.get("recipient")
or request.POST.get("invitee_email")
or ""
).strip()
if not raw:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
candidate = _resolve_recipient(raw)
is_self = candidate is not None and candidate == request.user
if is_self:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
# RoomInvite uses the resolved User's email when available (so a
# username-typed invite doesn't store the raw username as if it were
# an email); falls back to the raw input for unregistered addresses.
invitee_email = candidate.email if candidate else raw
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=invitee_email,
defaults={"status": RoomInvite.PENDING},
)
# Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None:
request.user.buds.add(candidate)
candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays null;
# banner FYI navigates to the room's gatekeeper page via Brief.room.
brief = Brief.objects.create(
owner=request.user,
post=None,
room=room,
kind=Brief.KIND_GAME_INVITE,
title="Invite sent",
)
if is_ajax:
recipient_display = None
if candidate is not None:
recipient_display = candidate.username or candidate.email
return JsonResponse({
"brief": brief.to_banner_dict(),
"recipient_display": recipient_display,
})
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -43,7 +43,11 @@ def relative_ts(dt):
@register.filter @register.filter
def display_name(user): def display_name(user):
if user is None: # `getattr` guards: AnonymousUser has no `.email` attribute and is not
# None, so `if user is None` doesn't catch it. Render anonymous viewers
# as empty rather than crashing — the legacy ownerless-post path
# (Percival ch. 18) shows post detail to anon visitors.
if user is None or not getattr(user, "is_authenticated", False):
return "" return ""
if user.username: if user.username:
return user.username return user.username
@@ -55,8 +59,9 @@ def at_handle(user):
"""`@username` when the user has set one; falls back to the truncated """`@username` when the user has set one; falls back to the truncated
email otherwise (no `@` prefix on bare emails since the address itself email otherwise (no `@` prefix on bare emails since the address itself
already carries the `@`). Used in post.html to colour usernames in the already carries the `@`). Used in post.html to colour usernames in the
--quaUser palette key while leaving emails as-is.""" --quaUser palette key while leaving emails as-is. Anonymous viewers
if user is None: render as empty (legacy ownerless-post path)."""
if user is None or not getattr(user, "is_authenticated", False):
return "" return ""
if user.username: if user.username:
return f"@{user.username}" return f"@{user.username}"

View File

@@ -0,0 +1,116 @@
"""FT for the gatekeeper invite via #id_bud_btn slide-out.
Replaces the legacy inline `<form action="invite_gamer">` panel inside
the gatekeeper modal. The bud-btn lives at the upper-right corner of
the right sidebar (footer in landscape); slide-out hosts the email/
username field + OK btn. Submit fires async POST to
epic:invite_gamer w. Accept: application/json — server returns
{brief, recipient_display}, JS shows the slide-down Brief banner.
"""
from selenium.webdriver.common.by import By
from apps.billboard.models import Brief
from apps.epic.models import Room, RoomInvite
from apps.lyric.models import User
from .base import FunctionalTest
class GatekeeperBudBtnPresenceTest(FunctionalTest):
"""The bud-btn renders for the room owner during gate phase, and is
absent for non-owners (friend invites are owner-only)."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
def test_bud_btn_renders_for_owner(self):
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
def test_bud_btn_absent_for_non_owner(self):
# A registered non-owner viewer doesn't see the invite affordance.
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.room_url)
# Gatekeeper-specific element confirms page rendered
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
def test_legacy_invite_email_input_is_gone(self):
"""Sanity: the old inline form has been removed."""
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))
class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
"""OK on the bud-btn slide-out fires the async invite — RoomInvite
persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def _open_panel_and_invite(self, recipient):
bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
bud_btn.click()
recipient_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient_input.send_keys(recipient)
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
return bud_btn
def test_invite_creates_room_invite(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_spawns_game_invite_brief(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
Brief.objects.filter(
owner=self.owner, kind=Brief.KIND_GAME_INVITE,
).count(),
1,
))
def test_invite_renders_slide_down_banner(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
def test_invite_closes_panel_after_success(self):
bud_btn = self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))
def test_invite_username_resolves_to_user_email(self):
"""Username-typed invite stores the resolved User's email."""
self._open_panel_and_invite("alice")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_auto_adds_recipient_to_owner_buds(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all())
))

View File

@@ -127,12 +127,19 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
# 2. Founder invites friend # 2. Founder invites friend via the bud-btn slide-out (replaces
invite_input = self.wait_for( # the legacy inline #id_invite_email form post-bud-btn refactor).
lambda: self.browser.find_element(By.ID, "id_invite_email") bud_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_bud_btn")
) )
invite_input.send_keys("friend@test.io") bud_btn.click()
self.browser.find_element(By.ID, "id_invite_btn").click() recipient = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient.send_keys("friend@test.io")
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
# 3. Friend logs in, sees invitation in My Games # 3. Friend logs in, sees invitation in My Games
self.create_pre_authenticated_session("friend@test.io") self.create_pre_authenticated_session("friend@test.io")
self.browser.get(self.live_server_url + "/gameboard/") self.browser.get(self.live_server_url + "/gameboard/")

View File

@@ -112,11 +112,11 @@
z-index: 312; z-index: 312;
} }
// In landscape: shift gear btn and applet menus left of the footer right sidebar // In landscape: shift gear btn and applet menus into the footer-sidebar
// XL override below doubles sidebar to 8rem — centre items in the wider column. // column. Both gear-btn (3rem wide) and the menus are centred in the
// `var(--sidebar-w)` slot via `right: calc((var(--sidebar-w) - 3rem) / 2)`,
// which scales with the rem-fluid root — no per-breakpoint override.
@media (orientation: landscape) { @media (orientation: landscape) {
$sidebar-w: 4rem;
.gameboard-page, .gameboard-page,
.dashboard-page, .dashboard-page,
.wallet-page, .wallet-page,
@@ -124,8 +124,8 @@
.billboard-page, .billboard-page,
.billscroll-page { .billscroll-page {
> .gear-btn { > .gear-btn {
right: 1rem; right: calc((var(--sidebar-w) - 3rem) / 2);
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed bottom: 3.95rem;
top: auto; top: auto;
} }
} }
@@ -137,32 +137,12 @@
#id_room_menu, #id_room_menu,
#id_billboard_applet_menu, #id_billboard_applet_menu,
#id_billscroll_menu { #id_billscroll_menu {
right: 1rem; right: calc((var(--sidebar-w) - 3rem) / 2);
bottom: 6.6rem; bottom: 6.6rem;
top: auto; top: auto;
} }
} }
@media (orientation: landscape) and (min-width: 1800px) {
// Centre gear btn and menus in the doubled 8rem sidebar (was 0.5rem from right edge)
.gameboard-page,
.dashboard-page,
.wallet-page,
.room-page,
.billboard-page,
.billscroll-page {
> .gear-btn { right: 2.5rem; }
}
#id_dash_applet_menu,
#id_game_applet_menu,
#id_game_kit_menu,
#id_wallet_applet_menu,
#id_room_menu,
#id_billboard_applet_menu,
#id_billscroll_menu { right: 2.5rem; }
}
// ── Applet box visual shell (reusable outside the grid) ──── // ── Applet box visual shell (reusable outside the grid) ────
%applet-box { %applet-box {
border: border:

View File

@@ -1,3 +1,23 @@
// ── Fluid root rem: 1rem scales with viewport ─────────────────────────────
// All sidebar/h2/font sizes downstream are in rem, so redefining root
// font-size against `vmin` (smaller of vh/vw) gives us a single sliding
// scale that's invariant under phone rotation: rotating swaps width/height
// but vmin stays the same → 1rem stays the same → navbar/footer/h2 hold
// their size. Floor 14px on cramped viewports, ceiling 22px on huge ones.
// 2.4vmin hits 16px (browser default) at vmin=667 (iPhone SE landscape).
html {
font-size: clamp(14px, 2.4vmin, 22px);
}
// Layout custom properties — single source of truth for the landscape
// sidebar width (navbar/footer) + the rotated-h2 column slot to the right
// of the navbar. Container margin-left in landscape adds these so applets
// can't bleed under the wordmark.
:root {
--sidebar-w: 5rem;
--h2-col-w: 3rem;
}
body { body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -173,22 +193,42 @@ body {
max-width: inherit; max-width: inherit;
margin: 0 1rem; margin: 0 1rem;
// Two-span title: <span>BILL</span><span>POST</span>. First
// word (always 4 letters: BILL/DASH/GAME/etc.) gets 45% of
// the title width; the variable second word fills the
// remaining 55%. Letters within each span spread via
// text-align: justify + text-justify: inter-character. The
// first-span colour shifts to --quaUser so the two-tone
// heading reads "Bill | Post" / "Dash | Sky".
h2 { h2 {
display: flex;
font-size: 3rem; font-size: 3rem;
color: rgba(var(--secUser), 0.75); color: rgba(var(--secUser), 0.75);
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: justify;
text-align-last: justify;
text-justify: inter-character;
text-transform: uppercase; text-transform: uppercase;
text-shadow: text-shadow:
// 1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left) var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8)
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
; ;
span { > span {
text-align: justify;
text-align-last: justify;
text-justify: inter-character;
box-sizing: border-box;
}
// Padding-inline (logical) creates the natural visual gap
// between the two words at the 45/55 boundary — works for
// both portrait (horizontal) AND the landscape rotated
// wordmark (vertical-rl writing mode).
> span:first-child {
flex: 0 0 45%;
padding-inline-end: 0.4em;
color: rgba(var(--quaUser), 0.75); color: rgba(var(--quaUser), 0.75);
} }
> span:last-child {
flex: 0 0 55%;
padding-inline-start: 0.4em;
}
} }
} }
} }
@@ -201,31 +241,20 @@ body {
} }
} }
@media (orientation: landscape) and (max-width: 1100px) {
body .container {
.navbar {
h1 {
font-size: 1rem !important;
}
}
}
}
@media (orientation: landscape) { @media (orientation: landscape) {
$sidebar-w: 5rem;
// ── Sidebar layout: navbar ← left, footer → right ──────────────────────────── // ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
body { body {
flex-direction: row; flex-direction: row;
} }
// Navbar → fixed left sidebar // Navbar → fixed left sidebar (width derives from --sidebar-w which is
// fluid via the rem-redefine above; no per-breakpoint width jumps).
body .container .navbar { body .container .navbar {
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
height: 100vh; height: 100vh;
width: $sidebar-w; width: var(--sidebar-w);
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: none; border-bottom: none;
border-right: 0.1rem solid rgba(var(--secUser), 0.4); border-right: 0.1rem solid rgba(var(--secUser), 0.4);
@@ -292,8 +321,8 @@ body {
// Login form: offset from fixed sidebars in landscape // Login form: offset from fixed sidebars in landscape
.input-group { .input-group {
left: $sidebar-w; left: var(--sidebar-w);
right: $sidebar-w; right: var(--sidebar-w);
.navbar-text { .navbar-text {
writing-mode: horizontal-tb; writing-mode: horizontal-tb;
@@ -306,35 +335,47 @@ body {
} }
} }
// Container: fill center, compensate for fixed sidebars on both sides. // Container: fill center, compensate for fixed sidebars on both sides
// max-width: none overrides the @media (min-width: 1200px) rule above so the // AND for the rotated-h2 column on the left (so applets can't bleed
// container fills all available space between the two sidebars on wide screens. // under the wordmark — true aperture clipping).
// max-width: none overrides the @media (min-width: 1200px) rule above
// so the container fills all available space between the sidebars.
body .container { body .container {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: none; max-width: none;
margin-left: $sidebar-w; margin-left: calc(var(--sidebar-w) + var(--h2-col-w));
margin-right: $sidebar-w; margin-right: var(--sidebar-w);
padding: 0 0.5rem; padding: 0 0.5rem;
} }
// Header row: h2 rotates into the left gutter (just right of the navbar border). // Header row: h2 rotates into the dedicated --h2-col-w slot just right
// position:fixed takes h2 out of flow; .row collapses to zero height automatically. // of the navbar. position:fixed takes h2 out of flow; .row collapses
// to zero height automatically. Resets portrait flex so the rotated
// wordmark renders as one continuous title (not split 45/55 here).
body .container .row { body .container .row {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
body .container .row .col-lg-6 h2 { body .container .row .col-lg-6 h2 {
position: fixed; position: fixed;
left: 5rem; // $sidebar-w — flush with the navbar right border left: var(--sidebar-w);
width: var(--h2-col-w);
top: 50%; top: 50%;
height: 80vh; // explicit height so the flex 45/55 % basis resolves
transform: translateY(-50%) rotate(180deg); transform: translateY(-50%) rotate(180deg);
writing-mode: vertical-rl; writing-mode: vertical-rl; // inline axis becomes top-to-bottom; flex stacks on it
font-size: 1.5rem; font-size: 3rem; // rem-fluid → no min-height jumps
letter-spacing: 0.4em; letter-spacing: 0.4em;
margin: 0; margin: 0;
z-index: 85; z-index: 85;
pointer-events: none; pointer-events: none;
// Inherits display: flex + the per-span flex 45/55 + padding-inline
// boundary from the portrait base. With writing-mode: vertical-rl the
// flex axis runs vertically, so first-span (BILL) takes 45% of the
// height (becomes bottom 45% after rotate(180deg)) and second-span
// (POST) takes the upper 55%. padding-inline-end resolves to the
// bottom edge of the first span — natural break between words.
} }
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary) // Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
@@ -344,7 +385,7 @@ body {
position: fixed; position: fixed;
right: 0; right: 0;
top: 0; top: 0;
width: $sidebar-w; width: var(--sidebar-w);
height: 100vh; height: 100vh;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -371,14 +412,23 @@ body {
} }
} }
// ©2026 Dis Co. — single-line vertical strip at the right edge of
// the right sidebar (= the very right edge of the viewport in
// landscape). Reads bottom-to-top via writing-mode: vertical-rl +
// rotate(180deg) — same pattern as the navbar's rotated brand
// wordmark. Tucks into the empty 0.875rem gutter between the
// viewport edge and the centred icon/btn column, no overlap.
.footer-container { .footer-container {
position: absolute; position: absolute;
top: 0.25rem; right: 0.125rem;
text-align: center; top: auto;
line-height: 0.75 !important; line-height: 1 !important;
color: rgba(var(--secUser), 1); color: rgba(var(--secUser), 1);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
br { display: block; } br { display: none; }
small { small {
font-size: 0.75rem !important; font-size: 0.75rem !important;
@@ -387,16 +437,11 @@ body {
} }
} }
// Footer typography refinements that only kick in once the viewport is
// wide enough to clear the cramped phone-landscape regime. Sidebar
// dimensions themselves are now fluid via rem and don't need a per-
// breakpoint width override (the old ≥1800px doubling block is gone).
@media (orientation: landscape) and (min-width: 700px) { @media (orientation: landscape) and (min-width: 700px) {
body .container .row .col-lg-6 h2 {
@media (min-height: 400px) {
font-size: 2.5rem;
}
@media (min-height: 500px) {
font-size: 3rem;
}
}
body #id_footer { body #id_footer {
#id_footer_nav { #id_footer_nav {
gap: 3rem !important; gap: 3rem !important;
@@ -411,7 +456,8 @@ body {
.footer-container { .footer-container {
line-height: 1; line-height: 1;
margin-top: 0.5rem; // margin-top vestige of the absolute-top-anchored layout —
// dropped now that the rotated text is bottom-anchored.
small { small {
font-size: 1rem; font-size: 1rem;
@@ -420,61 +466,6 @@ body {
} }
} }
// ── XL landscape (≥1800px): double sidebar widths and scale content ────────────
@media (orientation: landscape) and (min-width: 1800px) {
$sidebar-xl: 8rem;
body .container .navbar {
width: $sidebar-xl;
.container-fluid {
gap: 2rem;
padding: 0 0.5rem;
}
.navbar-brand h1 { font-size: 2.4rem; }
.navbar-text { font-size: 0.78rem; } // 0.65rem × 1.2
// .btn-primary { width: 4rem; height: 4rem; font-size: 0.875rem; }
.input-group {
left: $sidebar-xl;
right: $sidebar-xl;
}
}
body .container {
margin-left: $sidebar-xl;
margin-right: $sidebar-xl;
}
// h2 page title: keep vertical rotation; shift left to clear the wider XL navbar.
body .container .row .col-lg-6 h2 {
left: 8rem; // $sidebar-xl
@media (min-height: 800px) {
font-size: 4.5rem;
}
}
body #id_footer {
width: $sidebar-xl;
#id_footer_nav {
gap: 8rem !important;
a { font-size: 3rem; }
}
.footer-container {
font-size: 0.85rem;
margin-top: 1rem;
small {
font-size: 1.2rem;
}
}
}
}
@media (orientation: portrait) and (max-width: 500px) { @media (orientation: portrait) and (max-width: 500px) {
body .container { body .container {
.navbar { .navbar {

View File

@@ -12,14 +12,15 @@
bottom: 0.5rem; bottom: 0.5rem;
left: 0.5rem; left: 0.5rem;
// In landscape, the bud-btn moves to the UPPER-RIGHT corner of the
// footer sidebar (top of the right sidebar). Centred within the
// var(--sidebar-w) column. Mirrors kit-btn / gear-btn (bottom-right)
// for the horizontal-centre formula, but anchors at top instead.
@media (orientation: landscape) { @media (orientation: landscape) {
left: 1rem; left: auto;
bottom: 0.5rem; right: calc((var(--sidebar-w) - 3rem) / 2);
top: auto; top: 0.5rem;
} bottom: auto;
@media (orientation: landscape) and (min-width: 1800px) {
left: 2.5rem; // mirror the doubled 8rem sidebar centring
} }
z-index: 318; z-index: 318;
@@ -63,13 +64,15 @@
opacity: 0; opacity: 0;
@media (orientation: landscape) { @media (orientation: landscape) {
left: calc(4rem + 0.5rem); // clear the navbar sidebar // Bud-btn lives at the top of the right sidebar in landscape, so
right: calc(4rem + 0.5rem); // clear the footer sidebar // the panel slides out leftward from the right edge along the top.
} // Clear both fixed sidebars; transform-origin flips to right so
// the closed state collapses into the bud-btn.
@media (orientation: landscape) and (min-width: 1800px) { top: 0.5rem;
left: calc(8rem + 0.5rem); bottom: auto;
right: calc(8rem + 0.5rem); left: calc(var(--sidebar-w) + 0.5rem);
right: calc(var(--sidebar-w) + 0.5rem);
transform-origin: right center;
} }
#id_recipient { #id_recipient {
@@ -122,8 +125,8 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
// ── Bud autocomplete suggestions (mirror of sky-place birth picker) ── // ── Bud autocomplete suggestions (mirror of sky-place birth picker) ──
// Sibling of #id_bud_panel (which has overflow:hidden for the scaleX // 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). // 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 // Position-fixed; portrait sits ABOVE the panel (panel at bottom),
// at each breakpoint so the dropdown lines up. // landscape sits BELOW the panel (panel at top of viewport).
.bud-suggestions { .bud-suggestions {
position: fixed; position: fixed;
bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem) bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem)
@@ -138,13 +141,12 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
@media (orientation: landscape) { @media (orientation: landscape) {
left: calc(4rem + 0.5rem); // Panel now at top in landscape, so suggestions drop downward.
right: calc(4rem + 0.5rem); top: 4rem;
} bottom: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
@media (orientation: landscape) and (min-width: 1800px) { left: calc(var(--sidebar-w) + 0.5rem);
left: calc(8rem + 0.5rem); right: calc(var(--sidebar-w) + 0.5rem);
right: calc(8rem + 0.5rem);
} }
} }

View File

@@ -3,16 +3,17 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
// In landscape, centre the 3rem-wide btn within the var(--sidebar-w)
// footer sidebar — matches the bud-btn (bottom-left) + gear-btn (above
// kit-btn) formula. The clamp on the root rem means the sidebar scales
// fluidly, so this single rule covers all viewports without a per-
// breakpoint override.
@media (orientation: landscape) { @media (orientation: landscape) {
right: 1rem; right: calc((var(--sidebar-w) - 3rem) / 2);
bottom: 0.5rem; bottom: 0.5rem;
top: auto; top: auto;
} }
@media (orientation: landscape) and (min-width: 1800px) {
right: 2.5rem; // centre in doubled 8rem sidebar
}
z-index: 318; z-index: 318;
font-size: 1.75rem; font-size: 1.75rem;
cursor: pointer; cursor: pointer;

View File

@@ -0,0 +1,130 @@
{% load static %}
{% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _bud_invite_panel.html — bud btn + slide-out for the gatekeeper game- #}
{# invite flow. Replaces the legacy `<form action="invite_gamer">` #}
{# inline panel inside _gatekeeper.html. #}
{# #}
{# Differences from the post-share variant (_bud_panel.html): #}
{# • POSTs to epic:invite_gamer instead of billboard:share_post. #}
{# • Server returns {brief, recipient_display} — no line_text (no Post #}
{# to append a Line to). JS just shows the Brief banner. #}
{# #}
{# Caller must pass `room` in context. #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_bud_btn" type="button" aria-label="Invite a friend">
<i class="fa-solid fa-handshake"></i>
</button>
<div id="id_bud_panel"
data-invite-url="{% url 'epic:invite_gamer' room.id %}"
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<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 — sibling because the panel has overflow:hidden #}
{# for the slide-in scaleX animation. Pulls from request.user.buds. #}
<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; clicking inside them must
// NOT close+clear the panel.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});
ok.addEventListener('click', function () {
var recipient = input.value.trim();
if (!recipient) return;
var fd = new FormData();
fd.set('recipient', recipient);
fetch(panel.dataset.inviteUrl, {
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 (window.Brief && data.brief) Brief.showBanner(data.brief);
_close({ clear: true });
})
.catch(function () {
// Privacy-safe: even unregistered/self resolves to 200
// {brief: null}; only network/5xx land here. Just close.
});
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
}());
</script>

View File

@@ -1,7 +1,7 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% block title_text %}Billboard{% endblock title_text %} {% block title_text %}Billboard{% endblock title_text %}
{% block header_text %}<span>Bill</span>board{% endblock header_text %} {% block header_text %}<span>Bill</span><span>board</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="billboard-page"> <div class="billboard-page">

View File

@@ -2,7 +2,7 @@
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Billbuds{% endblock title_text %} {% block title_text %}Billbuds{% endblock title_text %}
{% block header_text %}<span>Bill</span>buds{% endblock header_text %} {% block header_text %}<span>Bill</span><span>buds</span>{% endblock header_text %}
{% block content %} {% block content %}

View File

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title_text %}Billnotes{% endblock title_text %} {% block title_text %}Billnotes{% endblock title_text %}
{% block header_text %}<span>Bill</span>notes{% endblock header_text %} {% block header_text %}<span>Bill</span><span>notes</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="note-page"> <div class="note-page">

View File

@@ -2,7 +2,7 @@
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Billposts{% endblock title_text %} {% block title_text %}Billposts{% endblock title_text %}
{% block header_text %}<span>Bill</span>posts{% endblock header_text %} {% block header_text %}<span>Bill</span><span>posts</span>{% endblock header_text %}
{% block content %} {% block content %}

View File

@@ -2,7 +2,7 @@
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Billpost{% endblock title_text %} {% block title_text %}Billpost{% endblock title_text %}
{% block header_text %}<span>Bill</span>post{% endblock header_text %} {% block header_text %}<span>Bill</span><span>post</span>{% endblock header_text %}
{% block content %} {% block content %}
@@ -14,14 +14,27 @@
<div class="post-page"> <div class="post-page">
<header class="post-header"> <header class="post-header">
<h3 class="post-title">{{ post.title }}</h3> <h3 class="post-title">{{ post.title }}</h3>
{% with recipients=post.shared_with.all %} {% if viewer_is_owner %}
{% if recipients %} {# Owner viewing — owner-centric prose. "shared between" lists #}
<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> {# every recipient; the self line is the owner's own handle. #}
{% if other_recipients %}
<p class="post-shared-recipients">shared between {% for r in other_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, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></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, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></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 %} {% else %}
{# Invitee viewing — "shared with" prose centred on the viewer #}
{# (request.user). Sole invitee collapses to a single line; the #}
{# "created by …" line attributes the post to its founder. #}
{% if other_recipients %}
<p class="post-shared-recipients">shared with {% for r in other_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, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
{% else %}
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
{% endif %}
<p class="post-created-by">created by <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% endif %}
</header> </header>
<ul id="id_post_table" class="post-lines"> <ul id="id_post_table" class="post-lines">

View File

@@ -1,7 +1,7 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% block title_text %}{{ room.name }} — Billscroll{% endblock %} {% block title_text %}{{ room.name }} — Billscroll{% endblock %}
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %} {% block header_text %}<span>Bill</span><span>scroll</span>{% endblock header_text %}
{% block content %} {% block content %}
{% csrf_token %} {% csrf_token %}

View File

@@ -4,9 +4,9 @@
{% block title_text %}Dashboard{% endblock title_text %} {% block title_text %}Dashboard{% endblock title_text %}
{% block header_text %} {% block header_text %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<span>Dash</span>board <span>Dash</span><span>board</span>
{% else %} {% else %}
<span>Howdy </span>stranger <span>Howdy </span><span>stranger</span>
{% endif %} {% endif %}
{% endblock header_text %} {% endblock header_text %}

View File

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title_text %}My Sky{% endblock title_text %} {% block title_text %}My Sky{% endblock title_text %}
{% block header_text %}<span>Dash</span>sky{% endblock header_text %} {% block header_text %}<span>Dash</span><span>sky</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="sky-page" <div class="sky-page"

View File

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title_text %}Dashwallet{% endblock title_text %} {% block title_text %}Dashwallet{% endblock title_text %}
{% block header_text %}<span>Dash</span>wallet{% endblock header_text %} {% block header_text %}<span>Dash</span><span>wallet</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="wallet-page"> <div class="wallet-page">

View File

@@ -59,16 +59,9 @@
</div> </div>
</div> </div>
{% if request.user == room.owner %} {# Legacy gate-invite-panel retired in favour of #id_bud_btn at #}
<div class="gate-invite-panel"> {# the upper-right of the footer (room.html includes the bud #}
<h3>Invite Friend</h3> {# invite panel partial when the viewer owns the room). #}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" style="flex:1; min-width:0;" hx-preserve>
<button type="submit" id="id_invite_btn" class="btn btn-confirm">OK</button>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title_text %}Game Kit{% endblock title_text %} {% block title_text %}Game Kit{% endblock title_text %}
{% block header_text %}<span>Game</span>Kit{% endblock header_text %} {% block header_text %}<span>Game</span><span>Kit</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="gameboard-page"> <div class="gameboard-page">

View File

@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title_text %}Gameboard{% endblock title_text %} {% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span>board{% endblock header_text %} {% block header_text %}<span>Game</span><span>board</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="gameboard-page"> <div class="gameboard-page">

View File

@@ -2,7 +2,7 @@
{% load static tooltip_tags %} {% load static tooltip_tags %}
{% block title_text %}Gameboard{% endblock title_text %} {% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span>room{% endblock header_text %} {% block header_text %}<span>Game</span><span>room</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="room-page" data-room-id="{{ room.id }}" <div class="room-page" data-room-id="{{ room.id }}"
@@ -92,6 +92,12 @@
{% endif %} {% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %}
{# Owner-only invite affordance: handshake btn at the upper-right #}
{# of the right sidebar w. slide-out + autocomplete. Replaces the #}
{# legacy inline `<form action="invite_gamer">` panel. #}
{% if request.user == room.owner %}
{% include "apps/billboard/_partials/_bud_invite_panel.html" %}
{% endif %}
{% endif %} {% endif %}
{% if room.table_status %} {% if room.table_status %}
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}> <div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}>
@@ -117,6 +123,9 @@
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{# Brief module — needed by _bud_invite_panel's OK handler so the #}
{# slide-down banner shows up on a successful gatekeeper invite. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/epic/room.js' %}"></script> <script src="{% static 'apps/epic/room.js' %}"></script>
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script> <script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script> <script src="{% static 'apps/epic/role-select.js' %}"></script>

View File

@@ -1,7 +1,7 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% block title_text %}Tarot — {{ room.name }}{% endblock title_text %} {% block title_text %}Tarot — {{ room.name }}{% endblock title_text %}
{% block header_text %}<span>Tarot</span> — {{ room.name }}{% endblock header_text %} {% block header_text %}<span>Tarot</span><span> — {{ room.name }}</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="tarot-page"> <div class="tarot-page">