post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD

- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
  - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
  - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
  - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
  - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
  - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
  - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
  - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
  - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
  - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 21:29:21 -04:00
parent ba5f6556c0
commit 6f76f6c176
28 changed files with 619 additions and 138 deletions

View File

@@ -18,7 +18,7 @@ class LineSerializer(serializers.ModelSerializer):
fields = ["id", "text"] fields = ["id", "text"]
class PostSerializer(serializers.ModelSerializer): class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField() name = serializers.ReadOnlyField(source="title")
url = serializers.CharField(source="get_absolute_url", read_only=True) url = serializers.CharField(source="get_absolute_url", read_only=True)
lines = LineSerializer(many=True, read_only=True) lines = LineSerializer(many=True, read_only=True)

View File

@@ -14,8 +14,8 @@ class BaseAPITest(TestCase):
class PostDetailAPITest(BaseAPITest): class PostDetailAPITest(BaseAPITest):
def test_returns_post_with_lines(self): def test_returns_post_with_lines(self):
post = Post.objects.create(owner=self.user) post = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post) Line.objects.create(text="line 1", post=post, author=self.user)
Line.objects.create(text="line 2", post=post) Line.objects.create(text="line 2", post=post, author=self.user)
response = self.client.get(f"/api/posts/{post.id}/") response = self.client.get(f"/api/posts/{post.id}/")
@@ -49,7 +49,7 @@ class PostLinesAPITest(BaseAPITest):
def test_cannot_add_duplicate_line_to_post(self): def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user) post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post) Line.objects.create(text="post line", post=post, author=self.user)
duplicate_response = self.client.post( duplicate_response = self.client.post(
f"/api/posts/{post.id}/lines/", f"/api/posts/{post.id}/lines/",
{"text": "post line"}, {"text": "post line"},
@@ -61,7 +61,7 @@ class PostLinesAPITest(BaseAPITest):
class PostsAPITest(BaseAPITest): class PostsAPITest(BaseAPITest):
def test_get_returns_only_users_posts(self): def test_get_returns_only_users_posts(self):
post1 = Post.objects.create(owner=self.user) post1 = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post1) Line.objects.create(text="line 1", post=post1, author=self.user)
other_user = User.objects.create_user("other@example.com") other_user = User.objects.create_user("other@example.com")
Post.objects.create(owner=other_user) Post.objects.create(owner=other_user)

View File

@@ -18,7 +18,7 @@ class PostLinesAPI(APIView):
post = get_object_or_404(Post, id=post_id) post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post}) serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid(): if serializer.is_valid():
serializer.save(post=post) serializer.save(post=post, author=request.user)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)
return Response(serializer.errors, status=400) return Response(serializer.errors, status=400)
@@ -29,8 +29,9 @@ class PostsAPI(APIView):
return Response(serializer.data) return Response(serializer.data)
def post(self, request): def post(self, request):
post = Post.objects.create(owner=request.user) text = request.data.get("text", "")
line = Line.objects.create(text=request.data.get("text", ""), post=post) post = Post.objects.create(owner=request.user, title=text[:35])
Line.objects.create(text=text, post=post, author=request.user)
serializer = PostSerializer(post) serializer = PostSerializer(post)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)

View File

@@ -13,10 +13,11 @@ class LineForm(forms.Form):
required=True, required=True,
) )
def save(self, for_post): def save(self, for_post, author):
return Line.objects.create( return Line.objects.create(
post=for_post, post=for_post,
text=self.cleaned_data["text"], text=self.cleaned_data["text"],
author=author,
) )
@@ -31,5 +32,5 @@ class ExistingPostLineForm(LineForm):
raise forms.ValidationError(DUPLICATE_LINE_ERROR) raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text return text
def save(self): def save(self, author):
return super().save(for_post=self._for_post) return super().save(for_post=self._for_post, author=author)

View File

@@ -0,0 +1,98 @@
# Adds Post.title, Line.author (PROTECT FK to lyric.User), Line.created_at.
# Backfills Post.title from first-line text (truncate 32 + "…" past 35 chars,
# or hardcoded "Notes & recognitions" for KIND_NOTE_UNLOCK), and Line.author
# from Post.owner — except KIND_NOTE_UNLOCK + ownerless rows, which attribute
# to the seeded `adman` User. Depends on lyric/0003_seed_adman so adman
# exists before backfill runs.
from django.db import migrations, models
from django.db.models import deletion
from django.utils import timezone
_NOTE_UNLOCK_TITLE = "Notes & recognitions"
def _truncate_title(text, length=35):
if len(text) <= length:
return text
return text[: length - 3] + "..."
def backfill(apps, schema_editor):
Post = apps.get_model("billboard", "Post")
Line = apps.get_model("billboard", "Line")
User = apps.get_model("lyric", "User")
adman = User.objects.filter(username="adman").first()
for post in Post.objects.all():
if post.kind == "note_unlock":
post.title = _NOTE_UNLOCK_TITLE
else:
first_line = post.lines.order_by("id").first()
post.title = _truncate_title(first_line.text) if first_line else ""
post.save(update_fields=["title"])
now = timezone.now()
for line in Line.objects.select_related("post").all():
if line.post.kind == "note_unlock":
line.author = adman
elif line.post.owner_id:
line.author_id = line.post.owner_id
else:
line.author = adman
if line.created_at is None:
line.created_at = now
line.save(update_fields=["author", "created_at"])
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("billboard", "0003_post_kind"),
("lyric", "0003_seed_adman"),
]
operations = [
migrations.AddField(
model_name="post",
name="title",
field=models.CharField(default="", max_length=35),
),
migrations.AddField(
model_name="line",
name="created_at",
field=models.DateTimeField(default=timezone.now),
),
migrations.AddField(
model_name="line",
name="author",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.RunPython(backfill, reverse_noop),
migrations.AlterField(
model_name="line",
name="author",
field=models.ForeignKey(
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.AlterField(
model_name="line",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -39,9 +39,12 @@ class Post(models.Model):
default=KIND_USER_POST, default=KIND_USER_POST,
) )
@property # Stored title — set explicitly on creation. Note-unlock Posts hardcode
def name(self): # "Notes & recognitions"; user_post Posts truncate first line to 35 chars
return self.lines.first().text # (32 + "..." past length). Replaces the legacy `name` property which
# gleaned `lines.first().text` lazily and broke if the first Line was
# later edited or deleted.
title = models.CharField(max_length=35, default="")
def get_absolute_url(self): def get_absolute_url(self):
return reverse("billboard:view_post", args=[self.id]) return reverse("billboard:view_post", args=[self.id])
@@ -50,9 +53,19 @@ class Post(models.Model):
class Line(models.Model): class Line(models.Model):
text = models.TextField(default="") text = models.TextField(default="")
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines") post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
# `author` PROTECTs against accidental sitewide-entity deletion (notably
# `adman`, the system-author for note_unlock + share_invite Lines).
# User-typed Lines attribute to the typing User; system-rendered Lines
# attribute to adman so the per-line "username" column always renders.
author = models.ForeignKey(
"lyric.User",
on_delete=models.PROTECT,
related_name="authored_lines",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
ordering = ("id",) ordering = ("created_at", "id")
unique_together = ("post", "text") unique_together = ("post", "text")
def __str__(self): def __str__(self):

View File

@@ -17,7 +17,7 @@ class BriefModelTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="brief@test.io") self.user = User.objects.create(email="brief@test.io")
self.post = Post.objects.create(owner=self.user) self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm") self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm", author=self.user)
def test_brief_defaults_unread(self): def test_brief_defaults_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line) b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
@@ -72,7 +72,7 @@ class ViewPostMarksReadTest(TestCase):
self.user = User.objects.create(email="reader@test.io") self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user) self.client.force_login(self.user)
self.post = Post.objects.create(owner=self.user) self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="entry one") self.line = Line.objects.create(post=self.post, text="entry one", author=self.user)
def test_get_view_post_flips_owner_unread_brief_to_read(self): def test_get_view_post_flips_owner_unread_brief_to_read(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line) b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
@@ -94,7 +94,7 @@ class ViewPostMarksReadTest(TestCase):
def test_get_does_not_flip_briefs_on_other_posts(self): def test_get_does_not_flip_briefs_on_other_posts(self):
other_post = Post.objects.create(owner=self.user) other_post = Post.objects.create(owner=self.user)
other_line = Line.objects.create(post=other_post, text="other") other_line = Line.objects.create(post=other_post, text="other", author=self.user)
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line) unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
self.client.get(reverse("billboard:view_post", args=[self.post.id])) self.client.get(reverse("billboard:view_post", args=[self.post.id]))
unrelated.refresh_from_db() unrelated.refresh_from_db()

View File

@@ -16,7 +16,7 @@ from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room from apps.epic.models import Room
from apps.epic.utils import rooms_for_user from apps.epic.utils import rooms_for_user
from apps.lyric.models import User from apps.lyric.models import User, get_or_create_adman
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS} _PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
@@ -219,14 +219,30 @@ def doff_title(request, slug):
# Templates also live under templates/apps/billboard/. URL names sit in the # Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix. # `billboard:` namespace so reversers across the codebase carry the prefix.
def _truncate_post_title(text, length=35):
"""Glean a Post.title from the first user-submitted Line: copy first
`length` chars exactly, or truncate to `length-3` chars + "..." past
that. Mirrors billboard/migrations/0004 backfill helper."""
if len(text) <= length:
return text
return text[: length - 3] + "..."
def new_post(request): def new_post(request):
form = LineForm(data=request.POST) form = LineForm(data=request.POST)
if form.is_valid(): if form.is_valid():
nupost = Post.objects.create() # Anonymous compose path (Percival ch. 18 chapters) keeps owner=null
# but still needs an author for the Line FK. We require auth on this
# view's caller paths in practice; no anonymous Lines reach prod.
author = request.user if request.user.is_authenticated else None
nupost = Post.objects.create(
title=_truncate_post_title(form.cleaned_data["text"]),
)
if request.user.is_authenticated: if request.user.is_authenticated:
nupost.owner = request.user nupost.owner = request.user
nupost.save() nupost.save()
form.save(for_post=nupost) if author is not None:
form.save(for_post=nupost, author=author)
return redirect(nupost) return redirect(nupost)
else: else:
context = { context = {
@@ -253,7 +269,7 @@ def view_post(request, post_id):
if request.method == "POST": if request.method == "POST":
form = ExistingPostLineForm(for_post=our_post, data=request.POST) form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save(author=request.user)
return redirect(our_post) return redirect(our_post)
# GET render is the FYI-read contract — flip every unread Brief on this # GET render is the FYI-read contract — flip every unread Brief on this
@@ -266,7 +282,7 @@ def view_post(request, post_id):
return render(request, "apps/billboard/post.html", { return render(request, "apps/billboard/post.html", {
"post": our_post, "post": our_post,
"form": form, "form": form,
"page_class": "page-billboard", "page_class": "page-billpost",
}) })
@@ -306,11 +322,13 @@ def share_post(request, post_id):
# response shape mustn't leak whether the email is on the system. Line # response shape mustn't leak whether the email is on the system. Line
# text carries an isoformat timestamp w/ microseconds so two rapid # text carries an isoformat timestamp w/ microseconds so two rapid
# shares of the same email don't collide on the # shares of the same email don't collide on the
# Line.unique_together(post, text) constraint. # Line.unique_together(post, text) constraint. System-authored as adman
# so the per-line "username" column renders the share announcement.
line_text = ( line_text = (
f"Shared with {recipient_email} at {timezone.now().isoformat()}" f"Shared with {recipient_email} at {timezone.now().isoformat()}"
) )
line = Line.objects.create(post=our_post, text=line_text) adman = get_or_create_adman()
line = Line.objects.create(post=our_post, text=line_text, author=adman)
brief = None brief = None
if request.user.is_authenticated: if request.user.is_authenticated:

View File

@@ -24,14 +24,21 @@ const Brief = (() => {
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.className = 'note-banner'; banner.className = 'note-banner';
// The square mirrors my_notes.html's .note-item__image-box (dashed
// border + "?" placeholder) when the brief carries a square_url —
// currently note_unlock kind, which jumps direct to /billboard/my-notes/.
const squareEl = brief.square_url const squareEl = brief.square_url
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image"></a>' ? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image note-item__image-box">?</a>'
: '<div class="note-banner__image"></div>'; : '<div class="note-banner__image"></div>';
// line_text is server-rendered prose from drama.Note.grant_if_new
// (and server-side share_post) — it may carry a `<a class="note-ref">`
// anchor wrapping the Note name. Insert as HTML, NOT escaped text.
// Title is plain (no HTML), so it stays escaped.
banner.innerHTML = banner.innerHTML =
'<div class="note-banner__body">' + '<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(brief.title) + '</p>' + '<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
'<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' + '<p class="note-banner__description">' + (brief.line_text || '') + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' + '<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
dateStr + dateStr +
'</time>' + '</time>' +

View File

@@ -7,19 +7,26 @@ from apps.billboard.forms import (
LineForm, LineForm,
) )
from apps.billboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User
class LineFormTest(TestCase): 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): def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create() mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"}) form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid()) 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, Line.objects.get())
self.assertEqual(new_line.text, "do re mi") self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost) self.assertEqual(new_line.post, mypost)
class ExistingPostLineFormTest(TestCase): class ExistingPostLineFormTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
def test_form_validation_for_blank_lines(self): def test_form_validation_for_blank_lines(self):
post = Post.objects.create() post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""}) form = ExistingPostLineForm(for_post=post, data={"text": ""})
@@ -28,7 +35,7 @@ class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_duplicate_lines(self): def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create() 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"}) form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR]) self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
@@ -37,5 +44,5 @@ class ExistingPostLineFormTest(TestCase):
mypost = Post.objects.create() mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"}) form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
new_line = form.save() new_line = form.save(author=self.author)
self.assertEqual(new_line, Line.objects.get()) self.assertEqual(new_line, Line.objects.get())

View File

@@ -7,64 +7,66 @@ from apps.lyric.models import User
class LineModelTest(TestCase): class LineModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_line_is_related_to_post(self): def test_line_is_related_to_post(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line() line = Line(post=mypost, author=self.user, text="x")
line.post = mypost
line.save() line.save()
self.assertIn(line, mypost.lines.all()) self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_post_lines(self): def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line(post=mypost, text=None) line = Line(post=mypost, author=self.user, text=None)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
line.save() line.save()
def test_cannot_save_empty_post_lines(self): def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line(post=mypost, text="") line = Line(post=mypost, author=self.user, text="")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
line.full_clean() line.full_clean()
def test_duplicate_lines_are_invalid(self): def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create() 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): with self.assertRaises(ValidationError):
line = Line(post=mypost, text="jklol") line = Line(post=mypost, author=self.user, text="jklol")
line.full_clean() line.full_clean()
def test_still_can_save_same_line_to_different_posts(self): def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create() post1 = Post.objects.create()
post2 = Post.objects.create() post2 = Post.objects.create()
Line.objects.create(post=post1, text="nojk") Line.objects.create(post=post1, author=self.user, text="nojk")
line = Line(post=post2, text="nojk") line = Line(post=post2, author=self.user, text="nojk")
line.full_clean() # should not raise line.full_clean() # should not raise
class PostModelTest(TestCase): class PostModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_get_absolute_url(self): def test_get_absolute_url(self):
mypost = Post.objects.create() mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/") self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/")
def test_post_lines_order(self): def test_post_lines_order(self):
post1 = Post.objects.create() post1 = Post.objects.create()
line1 = Line.objects.create(post=post1, text="i1") line1 = Line.objects.create(post=post1, author=self.user, text="i1")
line2 = Line.objects.create(post=post1, text="line 2") line2 = Line.objects.create(post=post1, author=self.user, text="line 2")
line3 = Line.objects.create(post=post1, text="3") line3 = Line.objects.create(post=post1, author=self.user, text="3")
self.assertEqual( self.assertEqual(
list(post1.lines.all()), list(post1.lines.all()),
[line1, line2, line3], [line1, line2, line3],
) )
def test_posts_can_have_owners(self): def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde") mypost = Post.objects.create(owner=self.user)
mypost = Post.objects.create(owner=user) self.assertIn(mypost, self.user.posts.all())
self.assertIn(mypost, user.posts.all())
def test_post_owner_is_optional(self): def test_post_owner_is_optional(self):
Post.objects.create() Post.objects.create()
def test_post_name_is_first_line_text(self): def test_post_title_is_explicit_field(self):
post = Post.objects.create() post = Post.objects.create(title="first line")
Line.objects.create(post=post, text="first line") self.assertEqual(post.title, "first line")
Line.objects.create(post=post, text="second line")
self.assertEqual(post.name, "first line")

View File

@@ -66,6 +66,13 @@ class NewPostTest(TestCase):
@override_settings(COMPRESS_ENABLED=False) @override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase): 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): def test_uses_post_template(self):
mypost = Post.objects.create() mypost = Post.objects.create()
response = self.client.get(f"/billboard/post/{mypost.id}/") 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): def test_displays_only_lines_for_that_post(self):
# Given/Arrange # Given/Arrange
correct_post = Post.objects.create() correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post) Line.objects.create(text="itemey 1", post=correct_post, author=self.author)
Line.objects.create(text="itemey 2", post=correct_post) Line.objects.create(text="itemey 2", post=correct_post, author=self.author)
other_post = Post.objects.create() 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 # When/Act
response = self.client.get(f"/billboard/post/{correct_post.id}/") response = self.client.get(f"/billboard/post/{correct_post.id}/")
# Then/Assert # Then/Assert
@@ -147,7 +154,7 @@ class PostViewTest(TestCase):
def test_duplicate_line_validation_errors_end_up_on_post_page(self): def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create() 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( response = self.client.post(
f"/billboard/post/{post1.id}/", f"/billboard/post/{post1.id}/",

View File

@@ -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.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions 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"] APPLET_ORDER = ["wallet", "username", "palette"]
@@ -112,6 +112,9 @@ def set_palette(request):
def set_profile(request): def set_profile(request):
if request.method == "POST": if request.method == "POST":
username = request.POST.get("username", "") 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.username = username
request.user.save(update_fields=["username"]) request.user.save(update_fields=["username"])
return redirect("/") return redirect("/")

View File

@@ -210,6 +210,15 @@ _NOTE_DISPLAY = {
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"}, "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): class Note(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
@@ -235,17 +244,37 @@ class Note(models.Model):
def display_greeting(self): def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,") 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 @classmethod
def grant_if_new(cls, user, slug): def grant_if_new(cls, user, slug):
"""Grants the Note if it doesn't already exist on the user; on a fresh """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" grant ALSO appends a Line to the user's per-category "Notes &
Post (creating the Post on first-ever unlock) and spawns a Brief that recognitions" Post (creating the Post on first-ever unlock) and spawns
FKs the appended Line. Returns ``(note, created, brief)`` — brief is a Brief that FKs the appended Line. Returns ``(note, created, brief)``
None on idempotent re-grants. Banner-side affordances (FYI navigation, — brief is None on idempotent re-grants. Banner-side affordances (FYI
my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator.""" 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 django.utils import timezone
from apps.billboard.models import Brief, Line, Post from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import get_or_create_adman
note, created = cls.objects.get_or_create( note, created = cls.objects.get_or_create(
user=user, slug=slug, user=user, slug=slug,
@@ -256,17 +285,37 @@ class Note(models.Model):
post, _ = Post.objects.get_or_create( post, _ = Post.objects.get_or_create(
owner=user, kind=Post.KIND_NOTE_UNLOCK, owner=user, kind=Post.KIND_NOTE_UNLOCK,
defaults={"title": NOTE_UNLOCK_POST_TITLE},
) )
# Per-category header Line (becomes Post.name) — only added once on # Existing Note-unlock Posts (pre-0004 migration) might lack a title
# first-ever unlock for this user. # if they predate this code path's get_or_create defaults. Heal once.
Line.objects.get_or_create(post=post, text="Look! — new Note unlocked") if post.title != NOTE_UNLOCK_POST_TITLE:
# Per-event Line — text dedupe is enforced by the unique_together on post.title = NOTE_UNLOCK_POST_TITLE
# (post, text), so two unlocks of the same slug at the same minute post.save(update_fields=["title"])
# would clash; the timestamp suffix carries the second of resolution.
# %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine username = user.username or user.email
# on Windows + Linux, and the leading zero is acceptable in a Line. note_anchor = (
line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}" f'<a class="note-ref" href="/billboard/my-notes/">'
line = Line.objects.create(post=post, text=line_text) f'{note.display_name}</a>'
)
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( brief = Brief.objects.create(
owner=user, owner=user,
post=post, post=post,

View File

@@ -45,7 +45,7 @@ class GrantIfNewSpawnsBriefTest(TestCase):
def test_two_different_grants_share_one_post(self): def test_two_different_grants_share_one_post(self):
"""Per-category Post: stargazer + schizo unlocks both append Lines to """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, "stargazer")
Note.grant_if_new(self.user, "schizo") Note.grant_if_new(self.user, "schizo")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK) posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
@@ -53,10 +53,11 @@ class GrantIfNewSpawnsBriefTest(TestCase):
post = posts.first() post = posts.first()
# 2 Briefs, one per unlock # 2 Briefs, one per unlock
self.assertEqual(Brief.objects.filter(post=post).count(), 2) self.assertEqual(Brief.objects.filter(post=post).count(), 2)
# 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked") # 2 distinct Lines on the Post — one per grant. The standalone
# + 2 per-event Lines (one per unlock) # "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)) 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): def test_brief_line_text_includes_note_title(self):
_, _, brief = Note.grant_if_new(self.user, "stargazer") _, _, brief = Note.grant_if_new(self.user, "stargazer")

View File

@@ -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),
]

View File

@@ -37,6 +37,47 @@ def resolve_pronouns(pronouns_key):
return row["subj"], row["obj"], row["poss"] 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): class UserManager(BaseUserManager):
def create_user(self, email): def create_user(self, email):
user = self.model(email=email) user = self.model(email=email)
@@ -99,6 +140,16 @@ class User(AbstractBaseUser):
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
USERNAME_FIELD = "email" 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 @property
def pronoun_subj(self): def pronoun_subj(self):
return resolve_pronouns(self.pronouns)[0] return resolve_pronouns(self.pronouns)[0]

View File

@@ -9,15 +9,30 @@ class PostPage:
self.test = test self.test = test
def get_table_rows(self): 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 <ul> of <li class="post-line">
# rows (no <ol> 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 @wait
def wait_for_row_in_post_table(self, line_text, line_number): def wait_for_row_in_post_table(self, line_text, line_number=None):
expected_row_text = f"{line_number}. {line_text}" # `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() 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): 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") return self.test.browser.find_element(By.ID, "id_text")
def add_post_line(self, line_text): def add_post_line(self, line_text):
@@ -60,4 +75,8 @@ class PostPage:
) )
def get_post_owner(self): def get_post_owner(self):
return self.test.browser.find_element(By.ID, "id_post_owner").text # `<span id="id_post_owner" hidden>` — 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")

View File

@@ -18,12 +18,12 @@ class LineValidationTest(FunctionalTest):
post_page.get_line_input_box().send_keys(Keys.ENTER) post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for( 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") post_page.get_line_input_box().send_keys("Purchase milk")
self.wait_for( 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) 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) post_page.wait_for_row_in_post_table("Purchase milk", 1)
self.wait_for( 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") post_page.get_line_input_box().send_keys("Make tea")
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, By.CSS_SELECTOR,
"#id_text:valid", 'input[name="text"]:valid',
) )
) )
post_page.get_line_input_box().send_keys(Keys.ENTER) post_page.get_line_input_box().send_keys(Keys.ENTER)

View File

@@ -99,8 +99,8 @@ from .base import FunctionalTest
def _seed_a_post(user): def _seed_a_post(user):
"""Create a Post w. one Line so view_post renders w/o redirect.""" """Create a Post w. one Line so view_post renders w/o redirect."""
p = Post.objects.create(owner=user) p = Post.objects.create(owner=user, title="seed line")
Line.objects.create(post=p, text="seed line") Line.objects.create(post=p, text="seed line", author=user)
return p return p
@@ -367,7 +367,7 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
cls = body.get_attribute("class") cls = body.get_attribute("class")
self.assertTrue( 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}", 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")) body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
cls = body.get_attribute("class") cls = body.get_attribute("class")
self.assertTrue( 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}", f"my_posts.html body class missing aperture marker: {cls!r}",
) )

View File

@@ -34,12 +34,14 @@
} }
html:has(body.page-billboard), html:has(body.page-billboard),
html:has(body.page-billscroll) { html:has(body.page-billscroll),
html:has(body.page-billpost) {
overflow: hidden; overflow: hidden;
} }
body.page-billboard, body.page-billboard,
body.page-billscroll { body.page-billscroll,
body.page-billpost {
overflow: hidden; overflow: hidden;
.container { .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 `<a class="note-ref">` 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 ───────────────────────────────────────────── // ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Notes stacked. // Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent Scroll spans full height. // Right column (8-wide): Most Recent Scroll spans full height.

View File

@@ -28,6 +28,7 @@
opacity: 0.75; opacity: 0.75;
} }
// Default (no square_url) — flat blue chip placeholder.
.note-banner__image { .note-banner__image {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
@@ -36,6 +37,18 @@
border-radius: 2px; border-radius: 2px;
} }
// Note-unlock variant — clickable <a> 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__nvm,
.note-banner__fyi { .note-banner__fyi {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -112,9 +112,9 @@
// plutonium (Pluto) // plutonium (Pluto)
--priPu: 29, 18, 38; --priPu: 29, 18, 38;
--secPu: 59, 44, 71; --secPu: 59, 44, 71;
--terPu: 84, 71, 97; --terPu: 89, 76, 102;
--quaPu: 109, 98, 128; --quaPu: 129, 118, 148;
--quiPu: 169, 155, 194; --quiPu: 189, 175, 214;
--sixPu: 235, 211, 217; --sixPu: 235, 211, 217;
/* Chroma Palette */ /* Chroma Palette */
@@ -189,8 +189,8 @@
--quiVt: 64, 30, 100; --quiVt: 64, 30, 100;
--sixVt: 43, 20, 66; --sixVt: 43, 20, 66;
// fuschia (A-Stone) // fuschia (A-Stone)
--priFs: 158, 61, 150; --priFs: 178, 71, 170;
--secFs: 133, 47, 126; --secFs: 138, 52, 131;
--terFs: 107, 31, 101; --terFs: 107, 31, 101;
--quaFs: 83, 17, 78; --quaFs: 83, 17, 78;
--quiFs: 61, 5, 56; --quiFs: 61, 5, 56;

View File

@@ -7,7 +7,7 @@
<ul> <ul>
{% for post in recent_posts %} {% for post in recent_posts %}
<li> <li>
<a href="{{ post.get_absolute_url }}">{{ post.name }}</a> <a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</li> </li>
{% empty %} {% empty %}
<li>No posts yet.</li> <li>No posts yet.</li>

View File

@@ -74,26 +74,70 @@
}); });
// OK → POST share-post async; reuses the C3.b response handling so the // 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 <ul> of <li class="post-line">
// rows; share-invite Lines are adman-authored (system prose, italic).
function _appendLine(text) { function _appendLine(text) {
var table = document.getElementById('id_post_table'); var list = document.getElementById('id_post_table');
if (!table) return; if (!list) return;
var n = table.querySelectorAll('tr').length + 1; var li = document.createElement('li');
var tr = document.createElement('tr'); li.className = 'post-line post-line--system';
var td = document.createElement('td'); var author = document.createElement('span');
td.textContent = n + '. ' + text; author.className = 'post-line-author';
tr.appendChild(td); author.textContent = 'adman';
table.appendChild(tr); 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 <p>
// 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) { function _appendRecipientChip(displayName) {
var box = document.getElementById('id_post_recipients'); if (!displayName) return;
if (!box || !displayName) return; var header = document.querySelector('.post-page .post-header');
var span = document.createElement('span'); if (!header) return;
span.className = 'post-recipient'; var existingRecipients = header.querySelector('.post-shared-recipients');
span.textContent = displayName; var selfLine = header.querySelector('.post-shared-self');
box.appendChild(document.createTextNode(' '));
box.appendChild(span); 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 () { ok.addEventListener('click', function () {

View File

@@ -8,13 +8,13 @@
<h3>{{ owner|display_name }}'s posts</h3> <h3>{{ owner|display_name }}'s posts</h3>
<ul> <ul>
{% for post in owner.posts.all %} {% for post in owner.posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li> <li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
<h3>Posts shared with me</h3> <h3>Posts shared with me</h3>
<ul> <ul>
{% for post in owner.shared_posts.all %} {% for post in owner.shared_posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li> <li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endblock content %} {% endblock content %}

View File

@@ -5,37 +5,57 @@
{% block header_text %}<span>Dash</span>post{% endblock header_text %} {% block header_text %}<span>Dash</span>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 %} {% block content %}
<div class="row justify-content-center"> {# Hidden owner span — preserves the existing FT contract that reads the #}
<div class="col-lg-6"> {# post owner via #id_post_owner. Visible owner attribution moved into the #}
<small>Post created by: <span id="id_post_owner">{{ post.owner|display_name }}</span></small> {# self line ("just me, …" / "& me, …") below. #}
<table id="id_post_table" class="table"> <span id="id_post_owner" hidden>{{ post.owner|display_name }}</span>
{% for line in post.lines.all %}
<tr><td>{{ forloop.counter }}. {{ line.text }}</td></tr>
{% endfor %}
</table>
</div>
</div>
<div class="row justify-content-center"> <div class="post-page">
<div class="col-lg-6"> <header class="post-header">
<small>Post shared with: <h3 class="post-title">{{ post.title }}</h3>
<span id="id_post_recipients"> {% with recipients=post.shared_with.all %}
{% for user in post.shared_with.all %} {% if recipients %}
<span class="post-recipient">{{ user|display_name }}</span> <p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient">{{ r|display_name }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
{% endfor %} <p class="post-shared-self">&amp; me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
</span> {% else %}
</small> <p class="post-shared-self">just me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
</div> {% endif %}
</div> {% endwith %}
</header>
<ul id="id_post_table" class="post-lines">
{% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|display_name }}</span>
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span>
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time>
</li>
{% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li>
</ul>
<form id="id_post_line_form" method="POST" action="{% url 'billboard:view_post' post.id %}" class="post-line-form">
{% csrf_token %}
<input
id="id_post_line_text"
name="text"
class="form-control{% if form.errors.text %} is-invalid{% endif %}"
placeholder="Enter a post line"
value="{{ form.text.value|default:'' }}"
aria-describedby="id_post_line_feedback"
required
/>
{% if form.errors %}
<div id="id_post_line_feedback" class="invalid-feedback">
{{ form.errors.text.0 }}
</div>
{% endif %}
</form>
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #} {# Buddy btn (bottom-left) + slide-out recipient field — async share. #}
{% include "apps/billboard/_partials/_buddy_panel.html" %} {% include "apps/billboard/_partials/_buddy_panel.html" %}
</div>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}

View File

@@ -3,7 +3,10 @@
<script src="{% static "apps/dashboard/note.js" %}"></script> <script src="{% static "apps/dashboard/note.js" %}"></script>
<script> <script>
window.onload = () => { window.onload = () => {
// #id_text — new-post applet on billboard.html;
// #id_post_line_text — post.html bottom-anchored aperture.
initialize("#id_text"); initialize("#id_text");
initialize("#id_post_line_text");
bindPaletteSwatches(); bindPaletteSwatches();
bindPaletteWheel(); bindPaletteWheel();
}; };