' +
'
' + _esc(brief.title) + '
' +
- '
' + _esc(brief.line_text) + '
' +
+ '
' + (brief.line_text || '') + '
' +
'
' +
dateStr +
' ' +
diff --git a/src/apps/dashboard/tests/integrated/test_forms.py b/src/apps/dashboard/tests/integrated/test_forms.py
index 89d8b98..204010f 100644
--- a/src/apps/dashboard/tests/integrated/test_forms.py
+++ b/src/apps/dashboard/tests/integrated/test_forms.py
@@ -7,19 +7,26 @@ from apps.billboard.forms import (
LineForm,
)
from apps.billboard.models import Line, Post
+from apps.lyric.models import User
class LineFormTest(TestCase):
+ def setUp(self):
+ self.author = User.objects.create(email="author@test.io")
+
def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid())
- new_line = form.save(for_post=mypost)
+ new_line = form.save(for_post=mypost, author=self.author)
self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost)
class ExistingPostLineFormTest(TestCase):
+ def setUp(self):
+ self.author = User.objects.create(email="author@test.io")
+
def test_form_validation_for_blank_lines(self):
post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""})
@@ -28,7 +35,7 @@ class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create()
- Line.objects.create(post=post, text="twins, basil")
+ Line.objects.create(post=post, text="twins, basil", author=self.author)
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
@@ -37,5 +44,5 @@ class ExistingPostLineFormTest(TestCase):
mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid())
- new_line = form.save()
+ new_line = form.save(author=self.author)
self.assertEqual(new_line, Line.objects.get())
diff --git a/src/apps/dashboard/tests/integrated/test_models.py b/src/apps/dashboard/tests/integrated/test_models.py
index 48a69ef..5310737 100644
--- a/src/apps/dashboard/tests/integrated/test_models.py
+++ b/src/apps/dashboard/tests/integrated/test_models.py
@@ -7,64 +7,66 @@ from apps.lyric.models import User
class LineModelTest(TestCase):
+ def setUp(self):
+ self.user = User.objects.create(email="a@b.cde")
+
def test_line_is_related_to_post(self):
mypost = Post.objects.create()
- line = Line()
- line.post = mypost
+ line = Line(post=mypost, author=self.user, text="x")
line.save()
self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create()
- line = Line(post=mypost, text=None)
+ line = Line(post=mypost, author=self.user, text=None)
with self.assertRaises(IntegrityError):
line.save()
def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create()
- line = Line(post=mypost, text="")
+ line = Line(post=mypost, author=self.user, text="")
with self.assertRaises(ValidationError):
line.full_clean()
def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create()
- Line.objects.create(post=mypost, text="jklol")
+ Line.objects.create(post=mypost, author=self.user, text="jklol")
with self.assertRaises(ValidationError):
- line = Line(post=mypost, text="jklol")
+ line = Line(post=mypost, author=self.user, text="jklol")
line.full_clean()
def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create()
post2 = Post.objects.create()
- Line.objects.create(post=post1, text="nojk")
- line = Line(post=post2, text="nojk")
+ Line.objects.create(post=post1, author=self.user, text="nojk")
+ line = Line(post=post2, author=self.user, text="nojk")
line.full_clean() # should not raise
class PostModelTest(TestCase):
+ def setUp(self):
+ self.user = User.objects.create(email="a@b.cde")
+
def test_get_absolute_url(self):
mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/")
def test_post_lines_order(self):
post1 = Post.objects.create()
- line1 = Line.objects.create(post=post1, text="i1")
- line2 = Line.objects.create(post=post1, text="line 2")
- line3 = Line.objects.create(post=post1, text="3")
+ line1 = Line.objects.create(post=post1, author=self.user, text="i1")
+ line2 = Line.objects.create(post=post1, author=self.user, text="line 2")
+ line3 = Line.objects.create(post=post1, author=self.user, text="3")
self.assertEqual(
list(post1.lines.all()),
[line1, line2, line3],
)
def test_posts_can_have_owners(self):
- user = User.objects.create(email="a@b.cde")
- mypost = Post.objects.create(owner=user)
- self.assertIn(mypost, user.posts.all())
+ mypost = Post.objects.create(owner=self.user)
+ self.assertIn(mypost, self.user.posts.all())
def test_post_owner_is_optional(self):
Post.objects.create()
- def test_post_name_is_first_line_text(self):
- post = Post.objects.create()
- Line.objects.create(post=post, text="first line")
- Line.objects.create(post=post, text="second line")
- self.assertEqual(post.name, "first line")
+ def test_post_title_is_explicit_field(self):
+ post = Post.objects.create(title="first line")
+ self.assertEqual(post.title, "first line")
diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py
index 492bd68..a1457f5 100644
--- a/src/apps/dashboard/tests/integrated/test_views.py
+++ b/src/apps/dashboard/tests/integrated/test_views.py
@@ -66,6 +66,13 @@ class NewPostTest(TestCase):
@override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase):
+ def setUp(self):
+ self.author = User.objects.create(email="author@test.io")
+ # POST flows append a Line with author=request.user — non-null FK
+ # since Line.author is required. force_login so the view's view_post
+ # path saves cleanly; anonymous compose is no longer supported.
+ self.client.force_login(self.author)
+
def test_uses_post_template(self):
mypost = Post.objects.create()
response = self.client.get(f"/billboard/post/{mypost.id}/")
@@ -85,10 +92,10 @@ class PostViewTest(TestCase):
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_post = Post.objects.create()
- Line.objects.create(text="itemey 1", post=correct_post)
- Line.objects.create(text="itemey 2", post=correct_post)
+ Line.objects.create(text="itemey 1", post=correct_post, author=self.author)
+ Line.objects.create(text="itemey 2", post=correct_post, author=self.author)
other_post = Post.objects.create()
- Line.objects.create(text="other post line", post=other_post)
+ Line.objects.create(text="other post line", post=other_post, author=self.author)
# When/Act
response = self.client.get(f"/billboard/post/{correct_post.id}/")
# Then/Assert
@@ -147,7 +154,7 @@ class PostViewTest(TestCase):
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
- Line.objects.create(post=post1, text="lorem ipsum")
+ Line.objects.create(post=post1, text="lorem ipsum", author=self.author)
response = self.client.post(
f"/billboard/post/{post1.id}/",
diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py
index c67a2de..3c3ba65 100644
--- a/src/apps/dashboard/views.py
+++ b/src/apps/dashboard/views.py
@@ -18,7 +18,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions
-from apps.lyric.models import PaymentMethod, Token, User, Wallet
+from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username
APPLET_ORDER = ["wallet", "username", "palette"]
@@ -112,6 +112,9 @@ def set_palette(request):
def set_profile(request):
if request.method == "POST":
username = request.POST.get("username", "")
+ if is_reserved_username(username, current_user=request.user):
+ messages.error(request, "That handle is reserved.")
+ return redirect("/")
request.user.username = username
request.user.save(update_fields=["username"])
return redirect("/")
diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py
index 428a23e..d73af80 100644
--- a/src/apps/drama/models.py
+++ b/src/apps/drama/models.py
@@ -210,6 +210,15 @@ _NOTE_DISPLAY = {
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
}
+# Note slugs whose grant prose uses the long admin format ("The administration
+# recognizes…") rather than the standard "Look!—new Note unlocked…" format.
+# Any slug not in this set gets the standard format.
+_ADMIN_NOTE_SLUGS = frozenset({"super-schizo", "super-nomad"})
+
+# Hardcoded title for the per-user "Note unlocks" Post — supplants any
+# first-line-glean for posts of kind=NOTE_UNLOCK.
+NOTE_UNLOCK_POST_TITLE = "Notes & recognitions"
+
class Note(models.Model):
user = models.ForeignKey(
@@ -235,17 +244,37 @@ class Note(models.Model):
def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
+ @property
+ def display_name(self):
+ """The Note's *name* (e.g., "Stargazer", "Super-Schizo") — the heading
+ rendered on the my-notes card. Distinct from `display_title` which is
+ the *recognition title* the user dons (e.g., "Schizoid Man" for
+ super-schizo). For all current slugs `slug.title()` recovers the right
+ casing (.title() capitalizes after non-letter chars, so "super-schizo"
+ → "Super-Schizo"); special-case in `_NOTE_DISPLAY[slug]["name"]` if a
+ future slug needs a different rendering."""
+ return _NOTE_DISPLAY.get(self.slug, {}).get("name", self.slug.title())
+
@classmethod
def grant_if_new(cls, user, slug):
"""Grants the Note if it doesn't already exist on the user; on a fresh
- grant ALSO appends a Line to the user's per-category "Note Unlocks"
- Post (creating the Post on first-ever unlock) and spawns a Brief that
- FKs the appended Line. Returns ``(note, created, brief)`` — brief is
- None on idempotent re-grants. Banner-side affordances (FYI navigation,
- my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator."""
+ grant ALSO appends a Line to the user's per-category "Notes &
+ recognitions" Post (creating the Post on first-ever unlock) and spawns
+ a Brief that FKs the appended Line. Returns ``(note, created, brief)``
+ — brief is None on idempotent re-grants. Banner-side affordances (FYI
+ navigation, my-notes square) ride on Brief.kind=NOTE_UNLOCK.
+
+ Line text dispatches by slug: admin-grant slugs (super-schizo,
+ super-nomad) use the long "The administration recognizes…" format;
+ every other slug uses the standard "Look!—new Note unlocked. {Note
+ name} recognizes {username} the {title}." format. Both wrap the Note
+ name in a `note-ref` anchor pointing at /billboard/my-notes/.
+ Author is hardcoded to the seeded `adman` User; the per-line username
+ column then attributes the Line correctly."""
from django.utils import timezone
from apps.billboard.models import Brief, Line, Post
+ from apps.lyric.models import get_or_create_adman
note, created = cls.objects.get_or_create(
user=user, slug=slug,
@@ -256,17 +285,37 @@ class Note(models.Model):
post, _ = Post.objects.get_or_create(
owner=user, kind=Post.KIND_NOTE_UNLOCK,
+ defaults={"title": NOTE_UNLOCK_POST_TITLE},
)
- # Per-category header Line (becomes Post.name) — only added once on
- # first-ever unlock for this user.
- Line.objects.get_or_create(post=post, text="Look! — new Note unlocked")
- # Per-event Line — text dedupe is enforced by the unique_together on
- # (post, text), so two unlocks of the same slug at the same minute
- # would clash; the timestamp suffix carries the second of resolution.
- # %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine
- # on Windows + Linux, and the leading zero is acceptable in a Line.
- line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}"
- line = Line.objects.create(post=post, text=line_text)
+ # Existing Note-unlock Posts (pre-0004 migration) might lack a title
+ # if they predate this code path's get_or_create defaults. Heal once.
+ if post.title != NOTE_UNLOCK_POST_TITLE:
+ post.title = NOTE_UNLOCK_POST_TITLE
+ post.save(update_fields=["title"])
+
+ username = user.username or user.email
+ note_anchor = (
+ f'
'
+ f'{note.display_name} '
+ )
+ if slug in _ADMIN_NOTE_SLUGS:
+ line_text = (
+ f"The administration recognizes {username} for {note_anchor}, "
+ f"which comes with the customary title of {note.display_title}. "
+ "This does not entail any additional benefits."
+ )
+ else:
+ line_text = (
+ f"Look!—new Note unlocked. {note_anchor} "
+ f"recognizes {username} the {note.display_title}."
+ )
+
+ # Lazy get-or-create: TransactionTestCase flushes the migration-seeded
+ # adman row, so tests that create superusers (which auto-grants
+ # super-schizo + super-nomad via the User post_save signal) need a
+ # safety net. Production migrations seed it once.
+ adman = get_or_create_adman()
+ line = Line.objects.create(post=post, text=line_text, author=adman)
brief = Brief.objects.create(
owner=user,
post=post,
diff --git a/src/apps/drama/tests/integrated/test_note_brief.py b/src/apps/drama/tests/integrated/test_note_brief.py
index 14ca98b..bd80e87 100644
--- a/src/apps/drama/tests/integrated/test_note_brief.py
+++ b/src/apps/drama/tests/integrated/test_note_brief.py
@@ -45,7 +45,7 @@ class GrantIfNewSpawnsBriefTest(TestCase):
def test_two_different_grants_share_one_post(self):
"""Per-category Post: stargazer + schizo unlocks both append Lines to
- the same Note Unlocks Post (one growing thread)."""
+ the same "Notes & recognitions" Post (one growing thread)."""
Note.grant_if_new(self.user, "stargazer")
Note.grant_if_new(self.user, "schizo")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
@@ -53,10 +53,11 @@ class GrantIfNewSpawnsBriefTest(TestCase):
post = posts.first()
# 2 Briefs, one per unlock
self.assertEqual(Brief.objects.filter(post=post).count(), 2)
- # 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked")
- # + 2 per-event Lines (one per unlock)
+ # 2 distinct Lines on the Post — one per grant. The standalone
+ # "Look! — new Note unlocked" header Line was dropped in the May-8b
+ # refactor; the standard format now embeds that text inline per Line.
line_texts = list(post.lines.values_list("text", flat=True))
- self.assertEqual(len(set(line_texts)), 3)
+ self.assertEqual(len(set(line_texts)), 2)
def test_brief_line_text_includes_note_title(self):
_, _, brief = Note.grant_if_new(self.user, "stargazer")
diff --git a/src/apps/lyric/migrations/0003_seed_adman.py b/src/apps/lyric/migrations/0003_seed_adman.py
new file mode 100644
index 0000000..09d27f9
--- /dev/null
+++ b/src/apps/lyric/migrations/0003_seed_adman.py
@@ -0,0 +1,29 @@
+from django.contrib.auth.hashers import make_password
+from django.db import migrations
+
+
+def seed_adman(apps, schema_editor):
+ User = apps.get_model("lyric", "User")
+ User.objects.get_or_create(
+ username="adman",
+ defaults={
+ "email": "adman@earthmanrpg.local",
+ "password": make_password(None),
+ "is_staff": False,
+ "is_superuser": False,
+ "searchable": False,
+ },
+ )
+
+
+def reverse_noop(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("lyric", "0002_user_pronouns"),
+ ]
+ operations = [
+ migrations.RunPython(seed_adman, reverse_noop),
+ ]
diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py
index a35324c..71a8ba3 100644
--- a/src/apps/lyric/models.py
+++ b/src/apps/lyric/models.py
@@ -37,6 +37,47 @@ def resolve_pronouns(pronouns_key):
return row["subj"], row["obj"], row["poss"]
+# ── Reserved usernames ────────────────────────────────────────────────────
+# Sitewide entities that shouldn't be impersonated by new account names.
+# Compared lower-case in username assignment paths (set_profile, etc.).
+# `adman` is the system author for Note-unlock + share-invite Lines (seeded
+# in lyric/0003_seed_adman). The author's handles (disco, discoman,
+# hamildong) are NOT in this set yet — discoman is the founder's actual
+# username and existing tests assign it; revisit if/when other-entity
+# impersonation becomes a concrete concern.
+
+RESERVED_USERNAMES = frozenset({"adman"})
+
+
+def is_reserved_username(name, current_user=None):
+ """True if `name` is reserved AND not already owned by `current_user`."""
+ n = (name or "").strip().lower()
+ if not n:
+ return False
+ if current_user is not None and (current_user.username or "").lower() == n:
+ return False
+ return n in RESERVED_USERNAMES
+
+
+def get_or_create_adman():
+ """Idempotent fetch of the sitewide `adman` User — system-author for
+ Note-unlock + share-invite Lines. Production migrations seed it once
+ (lyric/0003_seed_adman); TransactionTestCase flushes the row between
+ tests, so view code that authors Lines as adman calls this helper."""
+ from django.contrib.auth.hashers import make_password
+ adman, _ = User.objects.get_or_create(
+ username="adman",
+ defaults={
+ "email": "adman@earthmanrpg.local",
+ "password": make_password(None),
+ "is_staff": False,
+ "is_superuser": False,
+ "searchable": False,
+ },
+ )
+ return adman
+
+
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
@@ -99,6 +140,16 @@ class User(AbstractBaseUser):
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
+ @property
+ def active_title_display(self):
+ """Render-ready string for "{username} the {title}" attributions —
+ returns the donned Note's recognition title, or 'Earthman' when no
+ Note is donned. The 'Earthman' default mirrors the dashboard greeting
+ fallback in dashboard/views.home_page."""
+ if self.active_title_id:
+ return self.active_title.display_title
+ return "Earthman"
+
@property
def pronoun_subj(self):
return resolve_pronouns(self.pronouns)[0]
diff --git a/src/functional_tests/post_page.py b/src/functional_tests/post_page.py
index ab419ec..0d73a99 100644
--- a/src/functional_tests/post_page.py
+++ b/src/functional_tests/post_page.py
@@ -9,15 +9,30 @@ class PostPage:
self.test = test
def get_table_rows(self):
- return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table tr")
+ # Post-May08b: #id_post_table is now a
of
+ # rows (no numbering). The CSS selector still works since the
+ # id is preserved.
+ return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table .post-line")
@wait
- def wait_for_row_in_post_table(self, line_text, line_number):
- expected_row_text = f"{line_number}. {line_text}"
+ def wait_for_row_in_post_table(self, line_text, line_number=None):
+ # `line_number` retained for backwards compat with callers that
+ # passed a position counter — ignored now (lines order by created_at,
+ # numbering is gone). Match by text containment instead.
rows = self.get_table_rows()
- self.test.assertIn(expected_row_text, [row.text for row in rows])
+ row_texts = [row.text for row in rows]
+ self.test.assertTrue(
+ any(line_text in t for t in row_texts),
+ f"Line text {line_text!r} not in any row: {row_texts!r}",
+ )
def get_line_input_box(self):
+ # /billboard/ new-post applet uses #id_text (creates a fresh Post);
+ # post.html aperture uses #id_post_line_text (appends to existing).
+ # `add_post_line` is called from both contexts, so probe in order.
+ boxes = self.test.browser.find_elements(By.ID, "id_post_line_text")
+ if boxes:
+ return boxes[0]
return self.test.browser.find_element(By.ID, "id_text")
def add_post_line(self, line_text):
@@ -60,4 +75,8 @@ class PostPage:
)
def get_post_owner(self):
- return self.test.browser.find_element(By.ID, "id_post_owner").text
+ # `` — Selenium .text returns "" for
+ # hidden elements, so read textContent attribute instead.
+ return self.test.browser.find_element(
+ By.ID, "id_post_owner"
+ ).get_attribute("textContent")
diff --git a/src/functional_tests/test_applet_new_post_line_validation.py b/src/functional_tests/test_applet_new_post_line_validation.py
index 40c17a1..58f770b 100644
--- a/src/functional_tests/test_applet_new_post_line_validation.py
+++ b/src/functional_tests/test_applet_new_post_line_validation.py
@@ -18,12 +18,12 @@ class LineValidationTest(FunctionalTest):
post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for(
- lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
+ lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:invalid')
)
post_page.get_line_input_box().send_keys("Purchase milk")
self.wait_for(
- lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
+ lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:valid')
)
post_page.get_line_input_box().send_keys(Keys.ENTER)
@@ -33,14 +33,14 @@ class LineValidationTest(FunctionalTest):
post_page.wait_for_row_in_post_table("Purchase milk", 1)
self.wait_for(
- lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
+ lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:invalid')
)
post_page.get_line_input_box().send_keys("Make tea")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
- "#id_text:valid",
+ 'input[name="text"]:valid',
)
)
post_page.get_line_input_box().send_keys(Keys.ENTER)
diff --git a/src/functional_tests/test_buddy_btn.py b/src/functional_tests/test_buddy_btn.py
index c67133c..6792572 100644
--- a/src/functional_tests/test_buddy_btn.py
+++ b/src/functional_tests/test_buddy_btn.py
@@ -99,8 +99,8 @@ 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")
+ p = Post.objects.create(owner=user, title="seed line")
+ Line.objects.create(post=p, text="seed line", author=user)
return p
@@ -367,7 +367,7 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
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,
+ "page-billboard" in cls or "page-billpost" in cls,
f"post.html body class missing aperture marker: {cls!r}",
)
@@ -376,6 +376,6 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
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,
+ "page-billboard" in cls or "page-billpost" in cls,
f"my_posts.html body class missing aperture marker: {cls!r}",
)
diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss
index 9ea3c0f..0b713c3 100644
--- a/src/static_src/scss/_billboard.scss
+++ b/src/static_src/scss/_billboard.scss
@@ -34,12 +34,14 @@
}
html:has(body.page-billboard),
-html:has(body.page-billscroll) {
+html:has(body.page-billscroll),
+html:has(body.page-billpost) {
overflow: hidden;
}
body.page-billboard,
-body.page-billscroll {
+body.page-billscroll,
+body.page-billpost {
overflow: hidden;
.container {
@@ -112,6 +114,99 @@ body.page-billscroll {
}
}
+// ── Dashpost page (bottom-anchored thread + composer) ─────────────────────
+// Mirrors billscroll's flex-column / overflow-y / scroll-buffer pattern,
+// with the composer pinned at the bottom (flex-shrink: 0) so the thread
+// breathes against the viewport bottom and the input stays in reach.
+
+.post-page {
+ @extend %billboard-page-base;
+ display: flex;
+ flex-direction: column;
+ padding: 0.75rem;
+ gap: 0.5rem;
+
+ .post-header {
+ flex-shrink: 0;
+
+ .post-title {
+ margin: 0 0 0.25rem;
+ font-weight: bold;
+ }
+
+ .post-shared-recipients,
+ .post-shared-self {
+ margin: 0;
+ font-size: 0.85rem;
+ opacity: 0.75;
+ }
+ }
+
+ #id_post_table {
+ list-style: none;
+ margin: 0;
+ padding: 0 0.75rem 0 0;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ // Bottom-anchor: scroll buffer above the lines pushes them down
+ // until they fill from the bottom; once content exceeds the
+ // aperture, normal scrolling kicks in.
+ justify-content: flex-end;
+
+ .post-line {
+ display: grid;
+ grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
+ align-items: baseline;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+
+ .post-line-author {
+ font-weight: bold;
+ opacity: 0.75;
+ white-space: nowrap;
+ font-size: 0.85rem;
+ }
+
+ .post-line-text {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+ .post-line-time {
+ font-size: 0.75rem;
+ opacity: 0.5;
+ text-align: right;
+ white-space: nowrap;
+ }
+
+ // System-authored Lines (adman) get a subtler typographic key
+ // — the inline `` carries the emphasis.
+ &.post-line--system .post-line-text {
+ font-style: italic;
+ opacity: 0.85;
+ }
+ }
+
+ .post-line-buffer {
+ flex-shrink: 0;
+ height: 0.25rem;
+ }
+ }
+
+ .post-line-form {
+ flex-shrink: 0;
+ margin: 0;
+ padding-top: 0.25rem;
+
+ input.form-control {
+ width: 100%;
+ }
+ }
+}
+
// ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent Scroll spans full height.
diff --git a/src/static_src/scss/_note.scss b/src/static_src/scss/_note.scss
index d54e8ef..9c4a20c 100644
--- a/src/static_src/scss/_note.scss
+++ b/src/static_src/scss/_note.scss
@@ -28,6 +28,7 @@
opacity: 0.75;
}
+ // Default (no square_url) — flat blue chip placeholder.
.note-banner__image {
width: 3rem;
height: 3rem;
@@ -36,6 +37,18 @@
border-radius: 2px;
}
+ // Note-unlock variant — clickable jumping to /billboard/my-notes/.
+ // Mirrors the Stargazer dotted-`?` square in my_notes.html so a brief
+ // visually parses as a note-card preview. Pairs with the
+ // `note-item__image-box` class added on the JS side so the banner picks
+ // up the same dashed border + hover.
+ a.note-banner__image {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ }
+
.note-banner__nvm,
.note-banner__fyi {
flex-shrink: 0;
diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss
index 3229053..cbae84d 100644
--- a/src/static_src/scss/rootvars.scss
+++ b/src/static_src/scss/rootvars.scss
@@ -112,9 +112,9 @@
// plutonium (Pluto)
--priPu: 29, 18, 38;
--secPu: 59, 44, 71;
- --terPu: 84, 71, 97;
- --quaPu: 109, 98, 128;
- --quiPu: 169, 155, 194;
+ --terPu: 89, 76, 102;
+ --quaPu: 129, 118, 148;
+ --quiPu: 189, 175, 214;
--sixPu: 235, 211, 217;
/* Chroma Palette */
@@ -189,8 +189,8 @@
--quiVt: 64, 30, 100;
--sixVt: 43, 20, 66;
// fuschia (A-Stone)
- --priFs: 158, 61, 150;
- --secFs: 133, 47, 126;
+ --priFs: 178, 71, 170;
+ --secFs: 138, 52, 131;
--terFs: 107, 31, 101;
--quaFs: 83, 17, 78;
--quiFs: 61, 5, 56;
diff --git a/src/templates/apps/billboard/_partials/_applet-my-posts.html b/src/templates/apps/billboard/_partials/_applet-my-posts.html
index 998d6e8..366f1df 100644
--- a/src/templates/apps/billboard/_partials/_applet-my-posts.html
+++ b/src/templates/apps/billboard/_partials/_applet-my-posts.html
@@ -7,7 +7,7 @@
{% for post in recent_posts %}
- {{ post.name }}
+ {{ post.title }}
{% empty %}
No posts yet.
diff --git a/src/templates/apps/billboard/_partials/_buddy_panel.html b/src/templates/apps/billboard/_partials/_buddy_panel.html
index e4dc87d..b5c7f64 100644
--- a/src/templates/apps/billboard/_partials/_buddy_panel.html
+++ b/src/templates/apps/billboard/_partials/_buddy_panel.html
@@ -74,26 +74,70 @@
});
// OK → POST share-post async; reuses the C3.b response handling so the
- // recipient chip + brief banner + post-table line append all light up.
+ // recipient chip + brief banner + post-line append all light up.
+ // Post-May08b layout: #id_post_table is a of
+ // rows; share-invite Lines are adman-authored (system prose, italic).
function _appendLine(text) {
- var table = document.getElementById('id_post_table');
- if (!table) return;
- var n = table.querySelectorAll('tr').length + 1;
- var tr = document.createElement('tr');
- var td = document.createElement('td');
- td.textContent = n + '. ' + text;
- tr.appendChild(td);
- table.appendChild(tr);
+ var list = document.getElementById('id_post_table');
+ if (!list) return;
+ var li = document.createElement('li');
+ li.className = 'post-line post-line--system';
+ var author = document.createElement('span');
+ author.className = 'post-line-author';
+ author.textContent = 'adman';
+ var body = document.createElement('span');
+ body.className = 'post-line-text';
+ body.textContent = text;
+ var time = document.createElement('time');
+ time.className = 'post-line-time';
+ var now = new Date();
+ time.dateTime = now.toISOString();
+ time.textContent = now.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
+ li.appendChild(author);
+ li.appendChild(body);
+ li.appendChild(time);
+ // Insert before the trailing buffer if present
+ var buffer = list.querySelector('.post-line-buffer');
+ if (buffer) list.insertBefore(li, buffer);
+ else list.appendChild(li);
}
+ // The shared-with header lives outside #id_buddy_panel — it's two
+ // siblings under .post-header. State transitions:
+ // 0 → 1+ recipients : "just me, X" turns into
+ // "shared between {chip}" + "& me, X"
+ // ≥1 → +1 recipients: append chip + ", " separator before existing
+ // recipient(s).
function _appendRecipientChip(displayName) {
- var box = document.getElementById('id_post_recipients');
- if (!box || !displayName) return;
- var span = document.createElement('span');
- span.className = 'post-recipient';
- span.textContent = displayName;
- box.appendChild(document.createTextNode(' '));
- box.appendChild(span);
+ if (!displayName) return;
+ var header = document.querySelector('.post-page .post-header');
+ if (!header) return;
+ var existingRecipients = header.querySelector('.post-shared-recipients');
+ var selfLine = header.querySelector('.post-shared-self');
+
+ var chip = document.createElement('span');
+ chip.className = 'post-recipient';
+ chip.textContent = displayName;
+
+ if (existingRecipients) {
+ existingRecipients.appendChild(document.createTextNode(', '));
+ existingRecipients.appendChild(chip);
+ return;
+ }
+
+ // 0 → 1+ transition: build the recipients line, rewrite the self
+ // line from "just me, …" to "& me, …".
+ var recipientsLine = document.createElement('p');
+ recipientsLine.className = 'post-shared-recipients';
+ recipientsLine.appendChild(document.createTextNode('shared between '));
+ recipientsLine.appendChild(chip);
+ if (selfLine) {
+ header.insertBefore(recipientsLine, selfLine);
+ // Replace "just me," prefix with "& me,"
+ selfLine.textContent = selfLine.textContent.replace(/^just me,/, '& me,');
+ } else {
+ header.appendChild(recipientsLine);
+ }
}
ok.addEventListener('click', function () {
diff --git a/src/templates/apps/billboard/my_posts.html b/src/templates/apps/billboard/my_posts.html
index 2a83a0a..ea5133a 100644
--- a/src/templates/apps/billboard/my_posts.html
+++ b/src/templates/apps/billboard/my_posts.html
@@ -8,13 +8,13 @@
{{ owner|display_name }}'s posts
Posts shared with me
{% endblock content %}
diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html
index 48ccc45..86a3af3 100644
--- a/src/templates/apps/billboard/post.html
+++ b/src/templates/apps/billboard/post.html
@@ -5,37 +5,57 @@
{% block header_text %}Dash post{% endblock header_text %}
-{% block extra_header %}
- {% url "billboard:view_post" post.id as form_action %}
- {% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
-{% endblock extra_header %}
-
{% block content %}
-
-
-
Post created by: {{ post.owner|display_name }}
-
- {% for line in post.lines.all %}
- {{ forloop.counter }}. {{ line.text }}
- {% endfor %}
-
-
-
+{# Hidden owner span — preserves the existing FT contract that reads the #}
+{# post owner via #id_post_owner. Visible owner attribution moved into the #}
+{# self line ("just me, …" / "& me, …") below. #}
+{{ post.owner|display_name }}
-
-
- Post shared with:
-
- {% for user in post.shared_with.all %}
- {{ user|display_name }}
- {% endfor %}
-
-
-
-
+
+
+
+
+ {% for line in post.lines.all %}
+
+ {{ line.author|display_name }}
+ {# adman-authored Lines (note unlock + share invite system prose) carry an `` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}
+ {{ line.created_at|date:'g:i A' }}
+
+ {% endfor %}
+
+
+
+
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #}
{% include "apps/billboard/_partials/_buddy_panel.html" %}
+
{% endblock content %}
{% block scripts %}
diff --git a/src/templates/apps/dashboard/_partials/_scripts.html b/src/templates/apps/dashboard/_partials/_scripts.html
index 04fd6cc..b7e3f10 100644
--- a/src/templates/apps/dashboard/_partials/_scripts.html
+++ b/src/templates/apps/dashboard/_partials/_scripts.html
@@ -3,7 +3,10 @@