From e465b6a3b35b2beaa29b0a4333e074e5af848a45 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 19:00:28 -0400 Subject: [PATCH] =?UTF-8?q?buddy=20btn=20sprint=20scaffolding:=20TDD=20spe?= =?UTF-8?q?c=20+=20partial=20template=20+=20SCSS=20+=20page=5Fclass=20?= =?UTF-8?q?=E2=80=94=2015/16=20FTs=20green,=201=20captured-red=20for=20pos?= =?UTF-8?q?t-compaction=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-compaction handoff for the bottom-left handshake btn that replaces the inline share form on post.html. The full spec lives in functional_tests/test_buddy_btn.py — running it as a red-first TDD checklist for the next agent (or future Disco) to pick up after compaction. Scaffolding landed: - functional_tests/test_buddy_btn.py — 16 tests across 6 classes covering presence-only-on-post.html (B1–B3), bottom-left fixed positioning matching kit-btn dimensions, slide-out panel structure (closed/open width, OK btn = .btn-confirm, recipient input left-padding clears the glyph), kit↔buddy mutual-exclusion via opacity, click-outside + Escape dismiss + clear, OK→async share creates Brief + appends Line + chips the recipient + closes the panel + clears the field, and post.html / my_posts.html body class picks up the aperture marker (page-billboard). - templates/apps/billboard/_partials/_buddy_panel.html — the partial: + #id_buddy_panel housing #id_recipient and #id_buddy_ok (.btn.btn-confirm). Inline JS mirrors the game-kit.js click/escape/click-outside pattern, toggles html.buddy-open + .active on the btn, intercepts OK to POST share-post w. Accept:application/json (reuses C3.b shape — line_text + brief.to_banner_dict() + recipient_display), appends the chip + line in-DOM, Brief.showBanner shows the slide-down banner, _close clears the input. - templates/apps/billboard/post.html — drops the inline #id_share_form / #id_recipient / SHARE-primary block + its JS; includes the buddy panel partial at the end of {% block content %}. - billboard.views.view_post + my_posts now set page_class="page-billboard" so the body class hooks into the aperture SCSS group (the user noted post.html wasn't in that group; this brings it in). - static_src/scss/_buddy.scss — new partial: #id_buddy_btn fixed bottom-left mirror of #id_kit_btn (3rem circle, secUser border, .active state, transition: opacity 0.15s); #id_buddy_panel slide-out spans calc(100vw - 3rem) (1.5rem each side w. landscape sidebar carve-outs), transform: scaleX(0)→1 from left center on html.buddy-open, opacity 0→1, the recipient input gets padding 0 1rem 0 3.5rem so the glyph doesn't overlap. Mutual exclusion: html.buddy-open #id_kit_btn → opacity:0; html:has(#id_kit_bag_dialog[open]) #id_buddy_btn → opacity:0 (uses :has() per project convention; no JS-side kit-open class needed). - core.scss imports buddy after game-kit. 15/16 FTs green; the lone red is BuddyBtnOkSubmitsAsyncShareTest.test_ok_creates_brief_appends_line_and_chip — server flow works (Brief is created, recipient chip + line append in DOM both visible in the screendump), only the .note-banner injection isn't surfacing on post.html. Likely cause: note.js inserts after the first

, but post.html's only h2 is the rotated navbar header which is position:absolute, so the banner's geometry parents to that and falls outside the visible aperture. Two clean follow-ups for the post-compaction agent: (a) make Brief.showBanner pick a different anchor when h2.parentElement is position:absolute, or (b) define a #id_brief_banner_anchor in base.html under the page content and have showBanner prefer it. Also pending for post-compaction: update functional_tests.post_page.PostPage.share_post_with() to drive the new buddy-btn flow (click btn → type → click OK → wait for chip) so the legacy test_sharing FT keeps working — currently it still operates on the inline form selectors that no longer exist. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/billboard/views.py | 11 +- src/functional_tests/test_buddy_btn.py | 381 ++++++++++++++++++ src/static_src/scss/_buddy.scss | 120 ++++++ src/static_src/scss/core.scss | 1 + .../billboard/_partials/_buddy_panel.html | 136 +++++++ src/templates/apps/billboard/post.html | 82 +--- 6 files changed, 650 insertions(+), 81 deletions(-) create mode 100644 src/functional_tests/test_buddy_btn.py create mode 100644 src/static_src/scss/_buddy.scss create mode 100644 src/templates/apps/billboard/_partials/_buddy_panel.html diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 3fb7db1..3477342 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -263,7 +263,11 @@ def view_post(request, post_id): Brief.objects.filter( owner=request.user, post=our_post, is_unread=True, ).update(is_unread=False) - return render(request, "apps/billboard/post.html", {"post": our_post, "form": form}) + return render(request, "apps/billboard/post.html", { + "post": our_post, + "form": form, + "page_class": "page-billboard", + }) def my_posts(request, user_id): @@ -272,7 +276,10 @@ def my_posts(request, user_id): return redirect("/") if request.user.id != owner.id: return HttpResponseForbidden() - return render(request, "apps/billboard/my_posts.html", {"owner": owner}) + return render(request, "apps/billboard/my_posts.html", { + "owner": owner, + "page_class": "page-billboard", + }) def share_post(request, post_id): diff --git a/src/functional_tests/test_buddy_btn.py b/src/functional_tests/test_buddy_btn.py new file mode 100644 index 0000000..c67133c --- /dev/null +++ b/src/functional_tests/test_buddy_btn.py @@ -0,0 +1,381 @@ +"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button. + +Written red BEFORE implementation as a TDD handoff so the post-compaction +agent (or future Disco) can land the feature without losing intent. Run: + + python src/manage.py test functional_tests.test_buddy_btn + +All tests should be RED initially. Implementation lands when they go green. + +──────────────────────────────────────────────────────────────────────────── +SPEC SUMMARY +──────────────────────────────────────────────────────────────────────────── + + • A new #id_buddy_btn () sits bottom-left + of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same + fixed/circular/secUser-bordered look + .active-state styling. + • Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html, + included only by post.html (NOT the global base.html — buddy is post-only). + • Replaces the inline share form on post.html: typing the recipient now + happens in a slide-out under the buddy btn. + • Click #id_buddy_btn → recipient field grows L→R under it, spanning + `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 + text + placeholder don't overlap the btn glyph. + • An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the + field (replaces the legacy big SHARE .btn-primary). + • While the recipient field is open: html.buddy-open is set; #id_kit_btn + quickly eases to opacity 0. Symmetric: when html.kit-open is set, + #id_buddy_btn eases to opacity 0. + • Click OK → POST share-post async (existing C3.b endpoint), clears the + field, closes the slide-out, slide-down Brief banner appears. + • Click outside the field/btn → closes the slide-out, clears the field + (no Brief). Reopening shows the placeholder, not the prior typed value. + • Escape key closes the slide-out (same as kit btn pattern). + • post.html + my_posts.html should be aperture-styled (body.page-billboard + or new body.page-post if needed; the user noted post.html isn't + currently in the base/applet scss aperture group). + +──────────────────────────────────────────────────────────────────────────── +IMPLEMENTATION CHECKLIST (post-compaction) +──────────────────────────────────────────────────────────────────────────── + + 1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn + + slide-out form + inline JS. + 2. Edit templates/apps/billboard/post.html: + - Drop the inline `
` + #id_recipient + SHARE btn + block (the share JS moves into _buddy_panel.html). + - Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`. + 3. Edit billboard.views.view_post (or my_posts) context: + - "page_class": "page-billboard" (or new page-post) so the body class + picks up the aperture SCSS. + 4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss: + - #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn + (3rem circle, secUser border, .active state, etc.) + - #id_buddy_panel (the slide-out wrapper): position fixed, + left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when + kit btn visible — but mutual-exclusion makes that moot), bottom- + aligned w. the btn centre, transition transform/width L→R. + - html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; } + - html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; } + - The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on + the trailing edge of the slide-out. + 5. JS in _buddy_panel.html: + - Mirror game-kit.js click/escape/click-outside pattern. + - Toggle html.buddy-open + #id_buddy_btn.active. + - On submit/OK: fetch POST share-post w. Accept:application/json, + reuse the C3.b response handling (line append, banner via + Brief.showBanner, recipient_display chip append). + - On dismiss-without-OK: clear input.value. + 6. post.html + my_posts.html: add the body class hook so the aperture + SCSS engages (probably page-billboard already, just need to confirm). + 7. Update functional_tests.post_page.PostPage.share_post_with() to + drive the buddy-btn flow (click btn → type → click OK → wait for chip). + 8. Re-run test_sharing.SharingTest — should still pass once the page- + object mirrors the new flow. + +──────────────────────────────────────────────────────────────────────────── +KNOWN AT TIME OF WRITING +──────────────────────────────────────────────────────────────────────────── + - #id_kit_btn lives in templates/core/base.html (line ~55), styled in + static_src/scss/_game-kit.scss; toggled by apps/dashboard/static/.../game-kit.js. + - The C3.b share-post async endpoint accepts Accept: application/json, + returns {brief, line_text, recipient_display}; intercepted by the + inline JS in post.html's existing #id_share_form. That JS moves into + _buddy_panel.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. +""" + +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from apps.applets.models import Applet +from apps.billboard.models import Brief, Line, Post +from apps.lyric.models import User + +from .base import FunctionalTest + + +def _seed_a_post(user): + """Create a Post w. one Line so view_post renders w/o redirect.""" + p = Post.objects.create(owner=user) + Line.objects.create(post=p, text="seed line") + return p + + +class BuddyBtnPresenceTest(FunctionalTest): + """The buddy btn is post-only — present on post.html, absent elsewhere.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-posts", + defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, + ) + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + + # ── B1 ────────────────────────────────────────────────────────────────── + + def test_buddy_btn_renders_on_post_html(self): + 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")) + icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake") + self.assertIsNotNone(icon) + + # ── B2 ────────────────────────────────────────────────────────────────── + + def test_buddy_btn_absent_on_dashboard(self): + self.browser.get(self.live_server_url + "/") + # Allow page to settle + self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) + self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) + + # ── B3 ────────────────────────────────────────────────────────────────── + + def test_buddy_btn_absent_on_billboard_index(self): + self.browser.get(self.live_server_url + "/billboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) + self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) + + +class BuddyBtnPositionTest(FunctionalTest): + """The btn sits bottom-left, mirror of #id_kit_btn's bottom-right.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + def test_buddy_btn_is_fixed_bottom_left(self): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + cs = self.browser.execute_script( + "var s = getComputedStyle(arguments[0]); " + "return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};", + btn, + ) + self.assertEqual(cs["position"], "fixed") + # bottom + left are non-auto/0; right is auto (or large) so the btn + # hugs the bottom-left corner. + self.assertNotEqual(cs["bottom"], "auto") + self.assertNotEqual(cs["left"], "auto") + + def test_buddy_btn_size_matches_kit_btn(self): + """Same circular-3rem look — visually a mirror pair.""" + btn = self.browser.find_element(By.ID, "id_buddy_btn") + kit = self.browser.find_element(By.ID, "id_kit_btn") + b_box = btn.size + k_box = kit.size + self.assertEqual(b_box["width"], k_box["width"]) + self.assertEqual(b_box["height"], k_box["height"]) + + +class BuddyBtnSlideOutTest(FunctionalTest): + """Click the buddy btn → recipient field + OK btn slide out under it.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + def test_recipient_field_hidden_until_click(self): + """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 + viewport-spanning width yet.""" + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + # 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 + # input is not displayed at full width (a CSS-driven slide-out). + panel = self.browser.find_element(By.ID, "id_buddy_panel") + # Before click, panel visible-width should be < viewport / 2 (closed) + viewport_w = self.browser.execute_script("return window.innerWidth;") + self.assertLess(panel.size["width"], viewport_w / 2) + + def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn.click() + + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + self.assertTrue(recipient.is_displayed()) + + # 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") + self.assertEqual(ok.text.strip().upper(), "OK") + # Buddy btn picks up .active when open (mirror kit-btn pattern) + self.assertIn("active", btn.get_attribute("class")) + + def test_panel_spans_almost_full_viewport_when_open(self): + """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.click() + panel = self.browser.find_element(By.ID, "id_buddy_panel") + # Wait for the slide-out transition to settle + self.wait_for(lambda: self.assertGreater( + panel.size["width"], + self.browser.execute_script("return window.innerWidth;") * 0.7, + )) + + 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.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + pad = self.browser.execute_script( + "return parseFloat(getComputedStyle(arguments[0]).paddingLeft);", + recipient, + ) + # At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't + # overlap the placeholder/typed text. + self.assertGreaterEqual(pad, 32) + + +class BuddyKitMutualExclusionTest(FunctionalTest): + """When kit btn is active, buddy btn fades to 0 — and vice-versa.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + def test_buddy_active_fades_kit_btn(self): + buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + kit = self.browser.find_element(By.ID, "id_kit_btn") + buddy.click() + self.wait_for(lambda: self.assertEqual( + self.browser.execute_script( + "return parseFloat(getComputedStyle(arguments[0]).opacity);", + kit, + ), + 0.0, + )) + + def test_kit_active_fades_buddy_btn(self): + kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) + buddy = self.browser.find_element(By.ID, "id_buddy_btn") + kit.click() + self.wait_for(lambda: self.assertEqual( + self.browser.execute_script( + "return parseFloat(getComputedStyle(arguments[0]).opacity);", + buddy, + ), + 0.0, + )) + + +class BuddyBtnDismissTest(FunctionalTest): + """Click outside / Escape closes the panel; field is cleared; reopening + shows the placeholder, not the previously-typed value.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + def _open_and_type(self, text): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient.send_keys(text) + return btn, recipient + + def test_click_outside_dismisses_and_clears(self): + btn, recipient = self._open_and_type("alice@test.io") + # Click somewhere far from the panel + btn + body = self.browser.find_element(By.TAG_NAME, "body") + self.browser.execute_script( + "var e = new MouseEvent('click', {bubbles:true, clientX: 100, clientY: 100});" + "document.querySelector('h2').dispatchEvent(e);" + ) + self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) + # Reopening: input value should be cleared + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + self.assertEqual(recipient.get_attribute("value"), "") + + def test_escape_dismisses_and_clears(self): + btn, recipient = self._open_and_type("alice@test.io") + recipient.send_keys(Keys.ESCAPE) + self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + self.assertEqual(recipient.get_attribute("value"), "") + + +class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest): + """OK → POST share-post (Accept:application/json) → Brief banner + + recipient chip appended; field clears; panel closes.""" + + def setUp(self): + super().setUp() + self.sharer = User.objects.create(email="buddy@test.io") + self.recipient = User.objects.create(email="alice@test.io") + self.post = _seed_a_post(self.sharer) + self.create_pre_authenticated_session("buddy@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + 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.click() + recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient_input.send_keys("alice@test.io") + ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") + ok.click() + + # 1. Brief is created server-side + self.wait_for(lambda: self.assertEqual( + Brief.objects.filter(owner=self.sharer, kind=Brief.KIND_SHARE_INVITE).count(), + 1, + )) + # 2. Banner appears + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")) + # 3. .post-recipient chip appended + self.wait_for(lambda: self.assertTrue( + self.browser.find_elements(By.CSS_SELECTOR, ".post-recipient") + )) + # 4. Field cleared + recipient_input = self.browser.find_element(By.ID, "id_recipient") + self.assertEqual(recipient_input.get_attribute("value"), "") + # 5. Panel closed (btn no longer .active) + self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) + + +class PostHtmlAperturePageClassTest(FunctionalTest): + """post.html and my_posts.html should pick up the aperture body class + so they share the navbar/footer scroll-lock pattern w. sky.html etc.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-posts", + defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, + ) + self.gamer = User.objects.create(email="buddy@test.io") + self.post = _seed_a_post(self.gamer) + self.create_pre_authenticated_session("buddy@test.io") + + 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}/") + body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + cls = body.get_attribute("class") + self.assertTrue( + "page-billboard" in cls or "page-post" in cls, + f"post.html body class missing aperture marker: {cls!r}", + ) + + def test_my_posts_html_body_carries_billboard_or_post_page_class(self): + self.browser.get(self.live_server_url + f"/billboard/users/{self.gamer.id}/") + body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + cls = body.get_attribute("class") + self.assertTrue( + "page-billboard" in cls or "page-post" in cls, + f"my_posts.html body class missing aperture marker: {cls!r}", + ) diff --git a/src/static_src/scss/_buddy.scss b/src/static_src/scss/_buddy.scss new file mode 100644 index 0000000..bdaba24 --- /dev/null +++ b/src/static_src/scss/_buddy.scss @@ -0,0 +1,120 @@ +// ── Buddy btn (bottom-left mirror of #id_kit_btn) ───────────────────────── +// +// 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 +// active (.active class on btn + html.{kit|buddy}-open class on root), the +// other quickly fades to opacity 0. +// +// Spec: functional_tests/test_buddy_btn.py. + +#id_buddy_btn { + position: fixed; + bottom: 0.5rem; + left: 0.5rem; + + @media (orientation: landscape) { + left: 1rem; + bottom: 0.5rem; + top: auto; + } + + @media (orientation: landscape) and (min-width: 1800px) { + left: 2.5rem; // mirror the doubled 8rem sidebar centring + } + + z-index: 318; + font-size: 1.75rem; + cursor: pointer; + color: rgba(var(--secUser), 1); + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 1); + transition: opacity 0.15s ease; + + &.active { + color: rgba(var(--quaUser), 1); + border-color: rgba(var(--quaUser), 1); + } +} + +// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem. +#id_buddy_panel { + position: fixed; + bottom: 0.5rem; // align bottom edge w. buddy btn + left: 1.5rem; + right: 1.5rem; + height: 3rem; // match buddy btn height for vertical-centre alignment + z-index: 317; + display: flex; + align-items: center; + gap: 0.5rem; + pointer-events: none; + overflow: hidden; + + // Closed state — collapse leftward into the buddy btn + transform-origin: left center; + transform: scaleX(0); + transition: transform 0.2s ease-out, opacity 0.15s ease; + opacity: 0; + + @media (orientation: landscape) { + left: calc(4rem + 0.5rem); // clear the navbar sidebar + right: calc(4rem + 0.5rem); // clear the footer sidebar + } + + @media (orientation: landscape) and (min-width: 1800px) { + left: calc(8rem + 0.5rem); + right: calc(8rem + 0.5rem); + } + + #id_recipient { + flex: 1; + min-width: 0; + height: 100%; + // Generous left padding so the buddy btn glyph (3rem circle pinned + // at left:1.5rem) doesn't visually overlap the placeholder/typed text. + padding: 0 1rem 0 3.5rem; + background-color: rgba(var(--priUser), 1); + color: rgba(var(--secUser), 1); + border: 0.1rem solid rgba(var(--secUser), 0.5); + border-radius: 1.5rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: rgba(var(--terUser), 0.75); + box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5); + } + } + + .btn.btn-confirm { + flex-shrink: 0; + } +} + +// html.buddy-open: slide the panel out, fade the kit btn away. +html.buddy-open { + #id_buddy_panel { + transform: scaleX(1); + opacity: 1; + pointer-events: auto; + } + + #id_kit_btn { + opacity: 0; + pointer-events: none; + } +} + +// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open` +// 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. +html:has(#id_kit_bag_dialog[open]) #id_buddy_btn { + opacity: 0; + pointer-events: none; +} diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index 44a768f..9c0ac8c 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -13,6 +13,7 @@ @import 'note'; @import 'tooltips'; @import 'game-kit'; +@import 'buddy'; @import 'wallet-tokens'; diff --git a/src/templates/apps/billboard/_partials/_buddy_panel.html b/src/templates/apps/billboard/_partials/_buddy_panel.html new file mode 100644 index 0000000..e4dc87d --- /dev/null +++ b/src/templates/apps/billboard/_partials/_buddy_panel.html @@ -0,0 +1,136 @@ +{% load static %} +{# ─────────────────────────────────────────────────────────────────────── #} +{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #} +{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #} +{# right). Included by post.html only. #} +{# #} +{# Spec lives in functional_tests/test_buddy_btn.py — write it red-first. #} +{# Run: #} +{# python src/manage.py test functional_tests.test_buddy_btn #} +{# ─────────────────────────────────────────────────────────────────────── #} + + + +
+ + +
+ + diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index 2e2fa51..48ccc45 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -24,25 +24,6 @@
- - - {% csrf_token %} - - {% if form.errors.recipient %} -
- {{ form.errors.recipient.0 }} -
- {% endif %} - - - Post shared with: {% for user in post.shared_with.all %} @@ -52,68 +33,11 @@
+ + {# Buddy btn (bottom-left) + slide-out recipient field — async share. #} + {% include "apps/billboard/_partials/_buddy_panel.html" %} {% endblock content %} {% block scripts %} {% include "apps/dashboard/_partials/_scripts.html" %} - {% endblock scripts %}