pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD

- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-04 01:11:40 -04:00
parent 599d40decd
commit 29493c4f74
15 changed files with 393 additions and 14 deletions

View File

@@ -0,0 +1,33 @@
"""Seed the Pronouns applet on the Game Kit page (3x3, default visible)."""
from django.db import migrations
SLUG = "pronouns"
NAME = "Pronouns"
CONTEXT = "game-kit"
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug=SLUG,
defaults={
"name": NAME,
"context": CONTEXT,
"default_visible": True,
"grid_cols": 3,
"grid_rows": 3,
},
)
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug=SLUG).delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0004_rename_billboard_applet_slugs"),
]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -651,3 +651,39 @@ class SkyNatusDataViewTest(TestCase):
self.client.logout()
response = self.client.get("/dashboard/sky/data")
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)
class SetPronounsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="they@test.io")
self.client.force_login(self.user)
self.url = reverse("set_pronouns")
def test_post_valid_choice_persists_on_user(self):
response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, "misogyny")
def test_post_each_valid_choice(self):
for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
with self.subTest(key=key):
self.client.post(self.url, data={"pronouns": key})
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, key)
def test_post_invalid_choice_returns_400_and_does_not_change_user(self):
original = self.user.pronouns
response = self.client.post(self.url, data={"pronouns": "bogus"})
self.assertEqual(response.status_code, 400)
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, original)
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_requires_login(self):
self.client.logout()
response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 302)

View File

@@ -18,4 +18,5 @@ urlpatterns = [
path('sky/preview', views.sky_preview, name='sky_preview'),
path('sky/save', views.sky_save, name='sky_save'),
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
path('set-pronouns', views.set_pronouns, name='set_pronouns'),
]

View File

@@ -179,6 +179,20 @@ def set_profile(request):
request.user.save(update_fields=["username"])
return redirect("/")
@login_required(login_url="/")
def set_pronouns(request):
from django.http import HttpResponseNotAllowed
from apps.lyric.models import PRONOUN_TABLE
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
choice = request.POST.get("pronouns", "")
if choice not in PRONOUN_TABLE:
return HttpResponse(status=400)
request.user.pronouns = choice
request.user.save(update_fields=["pronouns"])
return HttpResponse(status=204)
@login_required(login_url="/")
def toggle_applets(request):
checked = request.POST.getlist("applets")

View File

@@ -1,11 +1,12 @@
from django.conf import settings
from django.db import models
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
# Later: replace with per-actor lookup when User model gains a pronouns field.
PRONOUN_SUBJ = "yo"
PRONOUN_OBJ = "yo"
PRONOUN_POSS = "yos"
from apps.lyric.models import resolve_pronouns
def _actor_pronouns(actor):
"""Return (subj, obj, poss) for an event actor; default = pluralism when None."""
return resolve_pronouns(getattr(actor, "pronouns", None) if actor else None)
class GameEvent(models.Model):
@@ -90,7 +91,8 @@ class GameEvent(models.Model):
ordinal = _ordinals[_chair_order.index(code)]
except ValueError:
ordinal = "?"
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
subj, _, _ = _actor_pronouns(self.actor)
return f"assumes {ordinal} Chair; {subj} will start the game as the {role}."
if self.verb == self.ROLES_REVEALED:
return "All roles assigned"
if self.verb == self.SIG_READY:
@@ -102,9 +104,16 @@ class GameEvent(models.Model):
abbrev = f" ({corner_rank}{icon_html})"
else:
abbrev = ""
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
# Trump cards ("The Schizo", "The Nomad", "The Wanderer") drop
# their "The " in this rendering: the prose template already
# supplies "the", and a levity/gravity qualifier (e.g. "Engraven"
# in "Engraven The Nomad") needs to butt up against the proper name.
card_name = card_name.replace("The ", "", 1)
_, _, poss = _actor_pronouns(self.actor)
return f"embodies as {poss} Significator the {card_name}{abbrev}."
if self.verb == self.SIG_UNREADY:
return f"disembodies {PRONOUN_POSS} Significator."
_, _, poss = _actor_pronouns(self.actor)
return f"disembodies {poss} Significator."
return self.verb
@property

View File

@@ -56,7 +56,8 @@ class GameEventModelTest(TestCase):
role="PC", role_display="Player")
prose = event.to_prose()
self.assertIn("Player", prose)
self.assertIn("yo will start the game", prose)
# Default user pronouns = pluralism → "they".
self.assertIn("they will start the game", prose)
# ── to_prose — SIG_READY ─────────────────────────────────────────────
@@ -65,7 +66,8 @@ class GameEventModelTest(TestCase):
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
# Default user pronouns = pluralism → "their".
self.assertIn("embodies as their Significator the Maid of Brands", prose)
self.assertIn("(M", prose)
self.assertIn("fa-wand-sparkles", prose)
@@ -73,15 +75,57 @@ class GameEventModelTest(TestCase):
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="The Wanderer", corner_rank="0", suit_icon="")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
# Trump card: leading "The " is stripped so qualifier (if any) butts up
# against the proper name and "the The Wanderer" never reads doubled.
self.assertIn("embodies as their Significator the Wanderer (0)", prose)
self.assertNotIn("the The Wanderer", prose)
self.assertNotIn("fa-", prose)
def test_sig_ready_prose_strips_leading_the_on_qualified_trump(self):
# Trump card with a levity/gravity qualifier already pre-pended in
# card_name: must read "the Engraven Nomad", not "the Engraven The Nomad".
# Default actor pronouns = pluralism → "their".
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Engraven The Nomad", corner_rank="0", suit_icon="")
prose = event.to_prose()
self.assertIn("embodies as their Significator the Engraven Nomad (0)", prose)
self.assertNotIn("Engraven The Nomad", prose)
def test_sig_ready_prose_uses_actor_pronouns_at_render_time(self):
# Bawlmorese actor → "yos"; default actor → "their"; switching
# the actor's pronouns updates ALL their existing prose on next render.
self.user.pronouns = "bawlmorese"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
self.assertIn("embodies as yos Significator the Maid of Brands", event.to_prose())
self.user.pronouns = "misogyny"
self.user.save(update_fields=["pronouns"])
# Re-fetch — the related object cache may hold the stale pronouns.
event.refresh_from_db()
self.assertIn("embodies as his Significator the Maid of Brands", event.to_prose())
def test_sig_unready_prose_uses_actor_pronouns(self):
self.user.pronouns = "misandry"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user)
self.assertIn("disembodies hers Significator", event.to_prose())
def test_role_selected_prose_uses_actor_pronouns(self):
self.user.pronouns = "misanthropy"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", role_display="Player")
self.assertIn("it will start the game", event.to_prose())
def test_sig_ready_prose_degrades_without_corner_rank(self):
# Old events recorded before this change have no corner_rank key
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
# Default user pronouns = pluralism → "their".
self.assertIn("embodies as their Significator the Maid of Brands", prose)
self.assertNotIn("(", prose)
def test_str_without_actor_shows_system(self):

View File

@@ -420,3 +420,70 @@ var GameKit = (function () {
_testNavigate: navigate,
};
}());
// ── Pronouns applet — guard-portal-confirmed preference flip ─────────────
(function () {
'use strict';
function csrfFromCookie() {
var name = 'csrftoken';
if (!document.cookie) return '';
var found = '';
document.cookie.split(';').forEach(function (c) {
c = c.trim();
if (c.indexOf(name + '=') === 0) {
found = decodeURIComponent(c.substring(name.length + 1));
}
});
return found;
}
function commit(card) {
var key = card.getAttribute('data-pronoun');
var body = new URLSearchParams({ pronouns: key });
fetch('/dashboard/set-pronouns', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfFromCookie(),
},
credentials: 'same-origin',
body: body.toString(),
}).then(function (resp) {
if (resp.status === 204) {
// Reload so any provenance prose currently on the page renders
// with the new pronouns and the .active class moves to the new card.
window.location.reload();
}
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function bindCards() {
var cards = document.querySelectorAll('.gk-pronoun-card');
if (!cards.length) return;
cards.forEach(function (card) {
card.addEventListener('click', function (e) {
e.stopPropagation();
if (typeof window.showGuard !== 'function') return;
var trio = card.getAttribute('data-trio') || '';
var msg = 'Set pronoun preference?'
+ '<span class="guard-pronoun-trio">' + escapeHtml(trio) + '</span>';
window.showGuard(card, msg, function () {
commit(card);
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindCards);
} else {
bindCards();
}
}());

View File

@@ -212,6 +212,7 @@ class GameKitViewTest(TestCase):
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
response = self.client.get("/gameboard/game-kit/")
self.parsed = lxml.html.fromstring(response.content)
@@ -247,7 +248,27 @@ class GameKitViewTest(TestCase):
def test_all_sections_visible_by_default(self):
sections = self.parsed.cssselect("#id_gk_sections_container section")
self.assertEqual(len(sections), 4)
# Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
self.assertEqual(len(sections), 5)
def test_pronouns_section_renders_five_cards(self):
[section] = self.parsed.cssselect("#id_gk_pronouns")
cards = section.cssselect(".gk-pronoun-card")
self.assertEqual(len(cards), 5)
slugs = [c.get("data-pronoun") for c in cards]
self.assertEqual(
slugs,
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
)
def test_pronouns_section_marks_current_choice_active(self):
# Default user pronouns = "pluralism" — that card should carry .active.
[active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
self.assertEqual(active.get("data-pronoun"), "pluralism")
def test_game_kit_applet_menu_has_pronouns_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
self.assertEqual(inp.get("type"), "checkbox")
class ToggleGameKitSectionsViewTest(TestCase):

View File

@@ -123,6 +123,7 @@ def unequip_deck(request, deck_id):
def _game_kit_context(user):
from apps.lyric.models import PRONOUN_CHOICES
coin = user.tokens.filter(token_type=Token.COIN).first()
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
carte = user.tokens.filter(token_type=Token.CARTE).first()
@@ -130,6 +131,10 @@ def _game_kit_context(user):
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
pronoun_options = [
{"key": k, "label": label, "active": (k == user.pronouns)}
for (k, label) in PRONOUN_CHOICES
]
return {
"coin": coin,
"pass_token": pass_token,
@@ -138,6 +143,8 @@ def _game_kit_context(user):
"tithe_tokens": tithe_tokens,
"unlocked_decks": list(user.unlocked_decks.all()),
"applets": applet_context(user, "game-kit"),
"pronoun_options": pronoun_options,
"current_pronouns": user.pronouns,
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-04 04:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='pronouns',
field=models.CharField(choices=[('pluralism', 'they/them/their'), ('bawlmorese', 'yo/yo/yos'), ('misogyny', 'he/him/his'), ('misandry', 'she/her/hers'), ('misanthropy', 'it/it/its')], default='pluralism', max_length=16),
),
]

View File

@@ -9,6 +9,34 @@ from django.urls import reverse
from django.utils import timezone
# ── Pronoun preference set ────────────────────────────────────────────────
# Drives provenance prose ("embodies as their Significator …") and any other
# user-facing referent. Default is pluralism (singular they) so a brand-new
# account renders neutrally; bawlmorese (yo/yo/yos) is the original Earthman
# default kept available as a Baltimore-flavoured option.
PRONOUN_CHOICES = [
("pluralism", "they/them/their"),
("bawlmorese", "yo/yo/yos"),
("misogyny", "he/him/his"),
("misandry", "she/her/hers"),
("misanthropy", "it/it/its"),
]
PRONOUN_TABLE = {
"pluralism": {"subj": "they", "obj": "them", "poss": "their"},
"bawlmorese": {"subj": "yo", "obj": "yo", "poss": "yos"},
"misogyny": {"subj": "he", "obj": "him", "poss": "his"},
"misandry": {"subj": "she", "obj": "her", "poss": "hers"},
"misanthropy": {"subj": "it", "obj": "it", "poss": "its"},
}
def resolve_pronouns(pronouns_key):
"""Return (subj, obj, poss) for a pronouns key, defaulting to pluralism."""
row = PRONOUN_TABLE.get(pronouns_key) or PRONOUN_TABLE["pluralism"]
return row["subj"], row["obj"], row["poss"]
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
@@ -63,10 +91,26 @@ class User(AbstractBaseUser):
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
pronouns = models.CharField(
max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
)
objects = UserManager()
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
@property
def pronoun_subj(self):
return resolve_pronouns(self.pronouns)[0]
@property
def pronoun_obj(self):
return resolve_pronouns(self.pronouns)[1]
@property
def pronoun_poss(self):
return resolve_pronouns(self.pronouns)[2]
def ensure_keypair(self):
"""Generate and persist an RSA-2048 keypair if not already set."""
if self.ap_public_key:

View File

@@ -79,6 +79,50 @@ class UserPaletteTest(TestCase):
user = User.objects.create(email="a@b.cde")
self.assertEqual(user.palette, "palette-default")
class UserPronounsTest(TestCase):
def test_pronouns_default_to_pluralism(self):
user = User.objects.create(email="they@test.io")
self.assertEqual(user.pronouns, "pluralism")
def test_pronouns_choices_include_all_five_options(self):
from apps.lyric.models import PRONOUN_CHOICES
keys = [k for (k, _) in PRONOUN_CHOICES]
self.assertEqual(
keys,
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
)
def test_pluralism_resolves_to_they_them_their(self):
user = User.objects.create(email="plural@test.io")
self.assertEqual(user.pronoun_subj, "they")
self.assertEqual(user.pronoun_obj, "them")
self.assertEqual(user.pronoun_poss, "their")
def test_bawlmorese_resolves_to_yo_yo_yos(self):
user = User.objects.create(email="yo@test.io", pronouns="bawlmorese")
self.assertEqual(user.pronoun_subj, "yo")
self.assertEqual(user.pronoun_obj, "yo")
self.assertEqual(user.pronoun_poss, "yos")
def test_misogyny_resolves_to_he_him_his(self):
user = User.objects.create(email="he@test.io", pronouns="misogyny")
self.assertEqual(user.pronoun_subj, "he")
self.assertEqual(user.pronoun_obj, "him")
self.assertEqual(user.pronoun_poss, "his")
def test_misandry_resolves_to_she_her_hers(self):
user = User.objects.create(email="she@test.io", pronouns="misandry")
self.assertEqual(user.pronoun_subj, "she")
self.assertEqual(user.pronoun_obj, "her")
self.assertEqual(user.pronoun_poss, "hers")
def test_misanthropy_resolves_to_it_it_its(self):
user = User.objects.create(email="it@test.io", pronouns="misanthropy")
self.assertEqual(user.pronoun_subj, "it")
self.assertEqual(user.pronoun_obj, "it")
self.assertEqual(user.pronoun_poss, "its")
class WalletCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")

View File

@@ -82,6 +82,7 @@ body.page-billscroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.75rem;
.scroll-buffer {
display: flex;

View File

@@ -188,7 +188,8 @@
.gk-deck-card,
.gk-trinket-card,
.gk-token-card {
.gk-token-card,
.gk-pronoun-card {
display: flex;
flex-direction: column;
align-items: center;
@@ -208,6 +209,32 @@
&:hover { border-color: rgba(var(--secUser), 0.8); }
}
.gk-pronoun-card {
// Card shows the ideology slug (pluralism, bawlmorese, …) in italic;
// the guard portal previews the actual slash trio above OK|NVM via JS.
// Active card is filled with the secondary tint so the user can see at a
// glance which preference is currently in effect.
.gk-pronoun-label {
font-size: 0.85rem;
letter-spacing: 0.05em;
text-transform: lowercase;
font-style: italic;
}
&.active {
border-color: rgba(var(--secUser), 1);
background: rgba(var(--secUser), 0.18);
}
}
#id_guard_portal .guard-pronoun-trio {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
letter-spacing: 0.06em;
opacity: 0.75;
text-align: center;
}
.gk-placeholder {
display: flex;
flex-direction: column;

View File

@@ -77,5 +77,18 @@
</div>
</section>
{% endif %}
{% if entry.applet.slug == 'pronouns' and entry.visible %}
<section id="id_gk_pronouns" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">
<h2>Pronouns</h2>
<div class="gk-items">
{% for opt in pronoun_options %}
<div class="gk-pronoun-card{% if opt.active %} active{% endif %}" data-pronoun="{{ opt.key }}" data-trio="{{ opt.label }}">
<span class="gk-pronoun-label">{{ opt.key }}</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
</div>