Compare commits

...

9 Commits

Author SHA1 Message Date
Disco DeDisco
11c85d56d1 fixed last of scroll position view in portrait mode to remember & display user's last line at bottom of applet viewport
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-24 19:11:27 -04:00
Disco DeDisco
8bab26e003 scroll position save fix attempt no. 1 feat. 'What happens next…?' text at bottom of scroll; buffer added to scroll, accounter for in FTs 2026-03-24 19:02:29 -04:00
Disco DeDisco
bc78d2c470 offloaded templates/core/_partials/_forthcoming.html to inject in any applet or other feature under construction; used immediately in Contacts billboard applet; styles updated accordingly 2026-03-24 18:40:16 -04:00
Disco DeDisco
2447315fd3 forgot to add latest migrations from apps.drama 2026-03-24 17:45:50 -04:00
Disco DeDisco
cde231d43c billscroll should now remember user's position across devices 2026-03-24 17:44:34 -04:00
Disco DeDisco
a0f8aeb791 similar pseudo-applet styling added to _scroll.html 2026-03-24 17:31:51 -04:00
Disco DeDisco
2ca4e9d39f fixed #id_gear_btn styling on billboard.html; removed redundant padding from %billboard-page-base 2026-03-24 17:22:49 -04:00
Disco DeDisco
c71f4eb68c styled more of Most Recent applet, allowing for scrolling of 36 most recent events and Load More link 2026-03-24 17:19:09 -04:00
Disco DeDisco
189d329e76 new applet structure for apps.billboard, incl. My Scrolls, Contacts & Most Recent applets; completely revamped _billboard.scss, tho some styling inconsistencies persist; ensured #id_billboard_applets_container inherited base styles found in _applets.scss; a pair of new migrations in apps.applets to support new applet models & fields; billboard gets its first ITs, new urls & views; pair of new FT classes in FTs.test_billboard 2026-03-24 16:46:46 -04:00
24 changed files with 949 additions and 90 deletions

View File

@@ -0,0 +1,48 @@
from django.db import migrations, models
def seed_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
def remove_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug__in=[
"billboard-my-scrolls",
"billboard-my-contacts",
"billboard-most-recent",
]).delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_gameboard_applet_heights"),
]
operations = [
migrations.AlterField(
model_name="applet",
name="context",
field=models.CharField(
choices=[
("dashboard", "Dashboard"),
("gameboard", "Gameboard"),
("wallet", "Wallet"),
("billboard", "Billboard"),
],
default="dashboard",
max_length=20,
),
),
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
]

View File

@@ -0,0 +1,29 @@
from django.db import migrations
def fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
# billboard-scroll belongs only to the billscroll page template, not the grid
Applet.objects.filter(slug="billboard-scroll").delete()
# Rename "My Contacts" → "Contacts"
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
def reverse_fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug="billboard-scroll",
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
)
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
class Migration(migrations.Migration):
dependencies = [
("applets", "0006_billboard_applets"),
]
operations = [
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
]

View File

@@ -4,10 +4,12 @@ class Applet(models.Model):
DASHBOARD = "dashboard" DASHBOARD = "dashboard"
GAMEBOARD = "gameboard" GAMEBOARD = "gameboard"
WALLET = "wallet" WALLET = "wallet"
BILLBOARD = "billboard"
CONTEXT_CHOICES = [ CONTEXT_CHOICES = [
(DASHBOARD, "Dashboard"), (DASHBOARD, "Dashboard"),
(GAMEBOARD, "Gameboard"), (GAMEBOARD, "Gameboard"),
(WALLET, "Wallet"), (WALLET, "Wallet"),
(BILLBOARD, "Billboard"),
] ]
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)

View File

View File

@@ -0,0 +1,184 @@
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
class BillboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billboard.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_uses_billboard_template(self):
response = self.client.get("/billboard/")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_passes_applets_context(self):
response = self.client.get("/billboard/")
self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("billboard-my-scrolls", slugs)
self.assertIn("billboard-my-contacts", slugs)
self.assertIn("billboard-most-recent", slugs)
def test_passes_my_rooms_context(self):
room = Room.objects.create(name="Test Room", owner=self.user)
response = self.client.get("/billboard/")
self.assertIn(room, response.context["my_rooms"])
def test_passes_recent_room_and_events(self):
room = Room.objects.create(name="Test Room", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(response.context["recent_room"], room)
self.assertEqual(len(response.context["recent_events"]), 1)
def test_recent_events_capped_at_36(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for i in range(40):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(len(response.context["recent_events"]), 36)
def test_recent_events_in_chronological_order(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for _ in range(3):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
events = response.context["recent_events"]
timestamps = [e.timestamp for e in events]
self.assertEqual(timestamps, sorted(timestamps))
def test_recent_room_is_none_when_no_events(self):
response = self.client.get("/billboard/")
self.assertIsNone(response.context["recent_room"])
self.assertEqual(list(response.context["recent_events"]), [])
class ToggleBillboardAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@toggle.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_toggle_hides_unchecked_applets(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["billboard-my-scrolls"]},
)
self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="billboard-my-contacts")
ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible)
def test_toggle_returns_partial_on_htmx(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["billboard-my-scrolls"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
class BillscrollViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billscroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_uses_room_scroll_template(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
def test_passes_events_context(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertIn("events", response.context)
self.assertEqual(response.context["events"].count(), 1)
def test_passes_page_class_billscroll(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["page_class"], "page-billscroll")
def test_passes_scroll_position_zero_when_none_saved(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 0)
def test_passes_saved_scroll_position_in_context(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_post_saves_scroll_position(self):
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 300},
)
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
self.assertEqual(sp.position, 300)
def test_post_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 450},
)
self.assertEqual(
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
)
def test_post_returns_204(self):
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 204)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 302)

View File

@@ -6,5 +6,7 @@ app_name = "billboard"
urlpatterns = [ urlpatterns = [
path("", views.billboard, name="billboard"), path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"), path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
] ]

View File

@@ -1,8 +1,10 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Max, Q
from django.shortcuts import render from django.shortcuts import redirect, render
from apps.drama.models import GameEvent from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.drama.models import GameEvent, ScrollPosition
from apps.epic.models import GateSlot, Room, RoomInvite from apps.epic.models import GateSlot, Room, RoomInvite
@@ -13,19 +15,72 @@ def billboard(request):
Q(gate_slots__gamer=request.user) | Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING) Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct().order_by("-created_at") ).distinct().order_by("-created_at")
recent_room = (
Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
)
.annotate(last_event=Max("events__timestamp"))
.filter(last_event__isnull=False)
.order_by("-last_event")
.distinct()
.first()
)
recent_events = (
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
if recent_room else []
)
return render(request, "apps/billboard/billboard.html", { return render(request, "apps/billboard/billboard.html", {
"my_rooms": my_rooms, "my_rooms": my_rooms,
"recent_room": recent_room,
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"page_class": "page-billboard", "page_class": "page-billboard",
}) })
@login_required(login_url="/")
def toggle_billboard_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="billboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
})
return redirect("billboard:billboard")
@login_required(login_url="/") @login_required(login_url="/")
def room_scroll(request, room_id): def room_scroll(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
events = room.events.select_related("actor").all() events = room.events.select_related("actor").all()
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
return render(request, "apps/billboard/room_scroll.html", { return render(request, "apps/billboard/room_scroll.html", {
"room": room, "room": room,
"events": events, "events": events,
"viewer": request.user, "viewer": request.user,
"page_class": "page-billboard", "scroll_position": sp.position if sp else 0,
"page_class": "page-billscroll",
}) })
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
room = Room.objects.get(id=room_id)
position = int(request.POST.get("position", 0))
ScrollPosition.objects.update_or_create(
user=request.user, room=room,
defaults={"position": position},
)
from django.http import HttpResponse
return HttpResponse(status=204)

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-03-24 21:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0001_initial'),
('epic', '0006_table_status_and_table_seat'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ScrollPosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'room')},
},
),
]

View File

@@ -73,7 +73,7 @@ class GameEvent(models.Model):
} }
code = d.get("role", "?") code = d.get("role", "?")
role = d.get("role_display") or _role_names.get(code, code) role = d.get("role_display") or _role_names.get(code, code)
return f"starts as {role}" return f"elects to start as {role}"
if self.verb == self.ROLES_REVEALED: if self.verb == self.ROLES_REVEALED:
return "All roles assigned" return "All roles assigned"
return self.verb return self.verb
@@ -83,6 +83,25 @@ class GameEvent(models.Model):
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}" return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}"
class ScrollPosition(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="scroll_positions",
)
room = models.ForeignKey(
"epic.Room", on_delete=models.CASCADE,
related_name="scroll_positions",
)
position = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("user", "room")]
def __str__(self):
return f"{self.user.email} @ {self.room.name}: {self.position}px"
def record(room, verb, actor=None, **data): def record(room, verb, actor=None, **data):
"""Record a game event in the drama log.""" """Record a game event in the drama log."""
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data) return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)

View File

@@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.db import IntegrityError
from apps.drama.models import GameEvent, record from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room from apps.epic.models import Room
from apps.lyric.models import User from apps.lyric.models import User
@@ -42,3 +43,31 @@ class GameEventModelTest(TestCase):
def test_str_without_actor_shows_system(self): def test_str_without_actor_shows_system(self):
event = record(self.room, GameEvent.ROLES_REVEALED) event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event)) self.assertIn("system", str(event))
class ScrollPositionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_can_save_scroll_position(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
self.assertEqual(ScrollPosition.objects.count(), 1)
self.assertEqual(sp.position, 150)
def test_default_position_is_zero(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
self.assertEqual(sp.position, 0)
def test_unique_per_user_and_room(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
with self.assertRaises(IntegrityError):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
def test_upsert_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
ScrollPosition.objects.update_or_create(
user=self.user, room=self.room,
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)

View File

@@ -1,6 +1,9 @@
import time
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet
from apps.drama.models import GameEvent, record from apps.drama.models import GameEvent, record
from apps.epic.models import Room, GateSlot from apps.epic.models import Room, GateSlot
from apps.lyric.models import User from apps.lyric.models import User
@@ -18,8 +21,17 @@ class BillboardScrollTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.founder = User.objects.create(email="founder@scroll.io") for slug, name, cols, rows in [
self.other = User.objects.create(email="other@scroll.io") ("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
self.founder = User.objects.create(email="founder@test.io")
self.other = User.objects.create(email="other@test.io")
self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder) self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder)
# Simulate two gate fills — one by founder, one by the other gamer # Simulate two gate fills — one by founder, one by the other gamer
record( record(
@@ -44,7 +56,7 @@ class BillboardScrollTest(FunctionalTest):
def test_footer_scroll_icon_leads_to_billboard_with_rooms(self): def test_footer_scroll_icon_leads_to_billboard_with_rooms(self):
# Founder logs in and lands on the dashboard # Founder logs in and lands on the dashboard
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/") self.browser.get(self.live_server_url + "/")
# Footer contains a scroll icon link pointing to /billboard/ # Footer contains a scroll icon link pointing to /billboard/
@@ -65,7 +77,7 @@ class BillboardScrollTest(FunctionalTest):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_scroll_shows_human_readable_event_log(self): def test_scroll_shows_human_readable_event_log(self):
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/") self.browser.get(self.live_server_url + "/billboard/")
# Click the room link to reach the scroll # Click the room link to reach the scroll
@@ -86,14 +98,14 @@ class BillboardScrollTest(FunctionalTest):
self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text) self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text)
# Role selection event is rendered as prose # Role selection event is rendered as prose
self.assertIn("starts as Player", scroll.text) self.assertIn("elects to start as Player", scroll.text)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 3 — current user's events are right-aligned; others' are left # # Test 3 — current user's events are right-aligned; others' are left #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_scroll_aligns_own_events_right_and_others_left(self): def test_scroll_aligns_own_events_right_and_others_left(self):
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/") self.browser.get(self.live_server_url + "/billboard/")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance") lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
@@ -111,3 +123,164 @@ class BillboardScrollTest(FunctionalTest):
# The other gamer's event mentions their display name # The other gamer's event mentions their display name
self.assertIn("other", theirs_events[0].text) self.assertIn("other", theirs_events[0].text)
class BillscrollPositionTest(FunctionalTest):
"""
FT: the user's scroll position in a billscroll is saved to the server
and restored when they return to the same scroll from any device/session.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@scrollpos.io")
self.room = Room.objects.create(name="Persistent Chamber", owner=self.founder)
# Enough events to make #id_drama_scroll scrollable
for i in range(20):
record(
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
slot_number=(i % 6) + 1, token_type="coin",
token_display=f"Coin-{i}", renewal_days=7,
)
def test_scroll_position_persists_across_sessions(self):
# 1. Log in and navigate to the room's billscroll
self.create_pre_authenticated_session("founder@scrollpos.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
scroll_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
# set position, and dispatch scroll event to trigger the debounced save.
# JS saves scrollTop + clientHeight (bottom-of-viewport); forced height is 150px.
scroll_top = 100
forced_height = 150
self.browser.execute_script("""
var el = arguments[0];
el.style.overflow = 'auto';
el.style.height = '150px';
el.scrollTop = arguments[1];
el.dispatchEvent(new Event('scroll'));
""", scroll_el, scroll_top)
# 3. Wait for debounce (800ms) + fetch to complete
time.sleep(3)
# 4. Navigate away and back in a fresh session
self.browser.get(self.live_server_url + "/billboard/")
self.create_pre_authenticated_session("founder@scrollpos.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 5. The saved position is reflected in the page's data attribute
scroll_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
buffer_px = self.browser.execute_script(
"return Math.round(parseFloat(getComputedStyle(document.documentElement).fontSize) * 2.5)"
)
restored = int(scroll_el.get_attribute("data-scroll-position"))
self.assertEqual(restored, scroll_top + forced_height + buffer_px)
class BillboardAppletsTest(FunctionalTest):
"""
FT: billboard page renders three applets in the grid — My Scrolls,
My Contacts, and Most Recent — with a functioning gear menu.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
def test_billboard_shows_three_applets(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. Assert all three applet sections present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls")
)
self.browser.find_element(By.ID, "id_applet_billboard_my_contacts")
self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
def test_billboard_my_scrolls_lists_rooms(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. My Scrolls applet contains a link to the room's scroll
self.wait_for(
lambda: self.assertIn(
"Arcane Assembly",
self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text,
)
)
def test_billboard_gear_btn_opens_applet_menu(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. Gear button is visible
gear_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
)
# 3. Menu is hidden before click
menu = self.browser.find_element(By.ID, "id_billboard_applet_menu")
self.assertFalse(menu.is_displayed())
# 4. Clicking gear opens the menu (JS click bypasses kit-bag overlap in headless)
self.browser.execute_script("arguments[0].click()", gear_btn)
self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed()))
class BillscrollAppletsTest(FunctionalTest):
"""
FT: billscroll page renders as a single full-width applet that fills
the viewport aperture and contains the room's drama events.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@billtest.io")
self.room = Room.objects.create(name="Spectral Council", owner=self.founder)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_billscroll_shows_full_width_applet(self):
# 1. Log in, navigate to the room's scroll
self.create_pre_authenticated_session("founder@billtest.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 2. The full-width applet section is present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_billboard_scroll")
)
def test_billscroll_applet_contains_drama_events(self):
# 1. Log in, navigate to the room's scroll
self.create_pre_authenticated_session("founder@billtest.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 2. Drama scroll is inside the applet and shows the event
scroll = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
self.assertIn("Coin-on-a-String", scroll.text)

View File

@@ -81,12 +81,14 @@
#id_game_applet_menu { @extend %applet-menu; } #id_game_applet_menu { @extend %applet-menu; }
#id_wallet_applet_menu { @extend %applet-menu; } #id_wallet_applet_menu { @extend %applet-menu; }
#id_room_menu { @extend %applet-menu; } #id_room_menu { @extend %applet-menu; }
#id_billboard_applet_menu { @extend %applet-menu; }
// Page-level gear buttons — fixed to viewport bottom-right // Page-level gear buttons — fixed to viewport bottom-right
.gameboard-page, .gameboard-page,
.dashboard-page, .dashboard-page,
.wallet-page, .wallet-page,
.room-page { .room-page,
.billboard-page {
> .gear-btn { > .gear-btn {
position: fixed; position: fixed;
bottom: 4.2rem; bottom: 4.2rem;
@@ -97,7 +99,8 @@
#id_dash_applet_menu, #id_dash_applet_menu,
#id_game_applet_menu, #id_game_applet_menu,
#id_wallet_applet_menu { #id_wallet_applet_menu,
#id_billboard_applet_menu {
position: fixed; position: fixed;
bottom: 6.6rem; bottom: 6.6rem;
right: 1rem; right: 1rem;
@@ -111,7 +114,8 @@
.gameboard-page, .gameboard-page,
.dashboard-page, .dashboard-page,
.wallet-page, .wallet-page,
.room-page { .room-page,
.billboard-page {
> .gear-btn { > .gear-btn {
right: calc(#{$sidebar-w} + 0.5rem); right: calc(#{$sidebar-w} + 0.5rem);
bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed
@@ -121,35 +125,16 @@
#id_dash_applet_menu, #id_dash_applet_menu,
#id_game_applet_menu, #id_game_applet_menu,
#id_wallet_applet_menu { #id_wallet_applet_menu,
#id_billboard_applet_menu {
right: calc(#{$sidebar-w} + 1rem); right: calc(#{$sidebar-w} + 1rem);
bottom: 6.6rem; // same as portrait, just shifted right of footer sidebar bottom: 6.6rem; // same as portrait, just shifted right of footer sidebar
top: auto; top: auto;
} }
} }
// ── Applets grid (shared across all boards) ──────────────── // ── Applet box visual shell (reusable outside the grid) ────
%applets-grid { %applet-box {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 99%,
transparent 100%
);
section {
border: border:
0.2rem solid rgba(var(--secUser), 0.5), 0.2rem solid rgba(var(--secUser), 0.5),
; ;
@@ -196,6 +181,31 @@
a { color: rgba(var(--terUser), 1); text-decoration: none; } a { color: rgba(var(--terUser), 1); text-decoration: none; }
} }
}
// ── Applets grid (shared across all boards) ────────────────
%applets-grid {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 99%,
transparent 100%
);
section {
@extend %applet-box;
grid-column: span var(--applet-cols, 12); grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3); grid-row: span var(--applet-rows, 3);
@@ -208,3 +218,4 @@
#id_applets_container { @extend %applets-grid; } #id_applets_container { @extend %applets-grid; }
#id_game_applets_container { @extend %applets-grid; } #id_game_applets_container { @extend %applets-grid; }
#id_wallet_applets_container { @extend %applets-grid; } #id_wallet_applets_container { @extend %applets-grid; }
#id_billboard_applets_container { @extend %applets-grid; }

View File

@@ -439,6 +439,16 @@ body {
} }
} }
.forthcoming {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
font-style: italic;
opacity: 0.6;
}
#id_guard_portal { #id_guard_portal {
display: none; display: none;
position: fixed; position: fixed;

View File

@@ -1,8 +1,20 @@
html:has(body.page-billboard) { // ── Shared aperture fill for both billboard pages ──────────────────────────
%billboard-page-base {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
position: relative;
}
html:has(body.page-billboard),
html:has(body.page-billscroll) {
overflow: hidden; overflow: hidden;
} }
body.page-billboard { body.page-billboard,
body.page-billscroll {
overflow: hidden; overflow: hidden;
.container { .container {
@@ -19,10 +31,122 @@ body.page-billboard {
} }
} }
// ── Billboard page (three-applet grid) ─────────────────────────────────────
.billboard-page { .billboard-page {
flex: 1; @extend %billboard-page-base;
min-width: 0; }
overflow-y: auto;
position: relative; // ── Billscroll page (single full-aperture applet) ──────────────────────────
padding: 0.75rem;
.billscroll-page {
@extend %billboard-page-base;
display: flex;
flex-direction: column;
padding: 0.75rem;
// The single scroll applet stretches to fill the remaining aperture
.applet-billboard-scroll {
@extend %applet-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
#id_drama_scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
.scroll-buffer {
display: flex;
justify-content: center;
align-items: baseline;
padding: 2rem 0 1rem;
opacity: 0.4;
font-size: 0.8rem;
text-transform: uppercase;
.scroll-buffer-text {
letter-spacing: 0.33em;
}
.scroll-buffer-dots {
display: inline-flex;
letter-spacing: 0;
span {
display: inline-block;
width: 0.7em;
text-align: center;
}
}
}
}
}
}
// ── Billboard applet placement ─────────────────────────────────────────────
// Explicit placement: My Scrolls + Contacts stack left, Most Recent fills right.
// Portrait override (container query) restores stacked full-width layout.
#id_billboard_applets_container {
#id_applet_billboard_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
#id_applet_billboard_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; }
#id_applet_billboard_most_recent { grid-column: 5 / span 8; grid-row: 1 / span 6; }
@container (max-width: 550px) {
#id_applet_billboard_my_scrolls,
#id_applet_billboard_my_contacts,
#id_applet_billboard_most_recent {
grid-column: 1 / span 12;
grid-row: span var(--applet-rows, 3);
}
}
}
// ── Most Recent applet — scrollable drama feed ─────────────────────────────
#id_applet_billboard_most_recent {
display: flex;
flex-direction: column;
.most-recent-room-link {
flex-shrink: 0;
margin-bottom: 0.25rem;
font-weight: bold;
}
#id_drama_scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.most-recent-load-more {
display: block;
padding-bottom: 0.5rem;
font-size: 0.8rem;
text-align: center;
}
}
// ── My Scrolls list ────────────────────────────────────────────────────────
#id_applet_billboard_my_scrolls {
.scroll-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
li {
padding: 0.25rem 0;
border-bottom: 1px solid rgba(var(--priUser), 0.15);
&:last-child { border-bottom: none; }
a { text-decoration: none; }
}
}
} }

View File

@@ -0,0 +1,34 @@
{% load lyric_extras %}
<section
id="id_applet_billboard_most_recent"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Most Recent</h2>
{% if recent_room %}
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-room-link">{{ recent_room.name }}</a>
<section id="id_drama_scroll" class="drama-scroll">
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-load-more">Load more….</a>
{% for event in recent_events %}
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
<span class="event-body">
<strong>{{ event.actor|display_name }}</strong>
{{ event.to_prose }}<br>
<time class="event-time" datetime="{{ event.timestamp|date:'c' }}">
{{ event.timestamp|date:"N j, g:i a" }}
</time>
</span>
</div>
{% empty %}
<p class="event-empty"><small>No events yet.</small></p>
{% endfor %}
</section>
{% else %}
<p><small>No recent activity.</small></p>
{% endif %}
</section>
<script>
(function() {
var scroll = document.getElementById('id_drama_scroll');
if (scroll) scroll.scrollTop = scroll.scrollHeight;
})();
</script>

View File

@@ -0,0 +1,7 @@
<section
id="id_applet_billboard_my_contacts"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Contacts</h2>
{% include "core/_partials/_forthcoming.html" %}
</section>

View File

@@ -0,0 +1,15 @@
<section
id="id_applet_billboard_my_scrolls"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>My Scrolls</h2>
<ul class="scroll-list">
{% for room in my_rooms %}
<li>
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
</li>
{% empty %}
<li><small>No scrolls yet.</small></li>
{% endfor %}
</ul>
</section>

View File

@@ -0,0 +1,59 @@
<section id="id_applet_billboard_scroll" class="applet-billboard-scroll">
<h2>{{ room.name }}</h2>
{% include "core/_partials/_scroll.html" %}
</section>
<script>
(function() {
var scroll = document.getElementById('id_drama_scroll');
if (!scroll) return;
// Defer dimension-dependent work until after flex layout resolves.
// Inline scripts can run before nested flex heights are computed, producing
// wrong scrollHeight/clientHeight values (symptom: incorrect marginTop on mobile).
requestAnimationFrame(function() {
// Push buffer so its top aligns with the bottom of the aperture when all
// events fit within the viewport (no natural scrolling).
var buffer = scroll.querySelector('.scroll-buffer');
if (buffer) {
var eventsHeight = scroll.scrollHeight - buffer.offsetHeight;
var gap = scroll.clientHeight - eventsHeight;
if (gap > 0) {
buffer.style.marginTop = gap + 'px';
}
}
// Restore: position stored is bottom-of-viewport; subtract clientHeight to align it
scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
});
// Animate "What happens next. . . ?" buffer dots — 4th span shows '?'
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
if (dotsWrap) {
var dots = dotsWrap.querySelectorAll('span');
var n = 0;
setInterval(function() {
dots.forEach(function(d, i) { d.textContent = i < n ? (i === 3 ? '?' : '.') : ''; });
n = (n + 1) % 5;
}, 400);
}
// Debounced save on scroll — store bottom-of-viewport so the last-read line is restored
var saveTimer;
scroll.addEventListener('scroll', function() {
clearTimeout(saveTimer);
saveTimer = setTimeout(function() {
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
var token = csrfToken ? csrfToken.value : '';
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
fetch("{% url 'billboard:save_scroll_position' room.id %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': token,
},
body: 'position=' + Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5),
});
}, 800);
});
})();
</script>

View File

@@ -0,0 +1,27 @@
<div id="id_billboard_applets_container">
<div id="id_billboard_applet_menu" style="display:none;">
<form
hx-post="{% url "billboard:toggle_applets" %}"
hx-target="#id_billboard_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% include "apps/applets/_partials/_applets.html" %}
</div>

View File

@@ -5,15 +5,7 @@
{% block content %} {% block content %}
<div class="billboard-page"> <div class="billboard-page">
<h2>My Scrolls</h2> {% include "apps/applets/_partials/_gear.html" with menu_id="id_billboard_applet_menu" %}
<ul class="game-list"> {% include "apps/billboard/_partials/_applets.html" %}
{% for room in my_rooms %}
<li>
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
</li>
{% empty %}
<li><small>No scrolls yet.</small></li>
{% endfor %}
</ul>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,8 +4,8 @@
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %} {% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
{% block content %} {% block content %}
<div class="billboard-page"> {% csrf_token %}
<h2>{{ room.name }}</h2> <div class="billscroll-page">
{% include "core/_partials/_scroll.html" %} {% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1 @@
<p class="forthcoming"><small>[Feature forthcoming]</small></p>

View File

@@ -1,5 +1,5 @@
{% load lyric_extras %} {% load lyric_extras %}
<section id="id_drama_scroll" class="drama-scroll"> <section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
{% for event in events %} {% for event in events %}
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}"> <div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
<span class="event-body"> <span class="event-body">
@@ -13,4 +13,12 @@
{% empty %} {% empty %}
<p class="event-empty"><small>No events yet.</small></p> <p class="event-empty"><small>No events yet.</small></p>
{% endfor %} {% endfor %}
<div class="scroll-buffer" aria-hidden="true">
<span class="scroll-buffer-text">What</span>
<span class="scroll-buffer-text quaUser">&nbsp;happens</span>
<span class="scroll-buffer-text terUser">&nbsp;next</span>
<span class="scroll-buffer-dots">
<span></span><span></span><span></span><span></span>
</span>
</div>
</section> </section>