diff --git a/src/apps/applets/migrations/0005_seed_pronouns_applet.py b/src/apps/applets/migrations/0005_seed_pronouns_applet.py new file mode 100644 index 0000000..c5c5a7a --- /dev/null +++ b/src/apps/applets/migrations/0005_seed_pronouns_applet.py @@ -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)] diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index a7c4ab1..7cf4f37 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -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) diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index ef787c2..94717b0 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -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'), ] diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index bb3c1f5..cd1273f 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -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") diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 8241fc9..4386e8c 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -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 diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 0ca5e6b..0bbd72d 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -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): diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js index e434566..403a00e 100644 --- a/src/apps/gameboard/static/apps/gameboard/game-kit.js +++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js @@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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?' + + '' + escapeHtml(trio) + ''; + window.showGuard(card, msg, function () { + commit(card); + }); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindCards); + } else { + bindCards(); + } +}()); diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 964efef..d2a6b1a 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -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): diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index b2181c7..31a0218 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -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, } diff --git a/src/apps/lyric/migrations/0002_user_pronouns.py b/src/apps/lyric/migrations/0002_user_pronouns.py new file mode 100644 index 0000000..119979b --- /dev/null +++ b/src/apps/lyric/migrations/0002_user_pronouns.py @@ -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), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 6bf460b..a35324c 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -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: diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 8543b32..0378d11 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -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") diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index 421d33d..9ea3c0f 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -82,6 +82,7 @@ body.page-billscroll { flex: 1; min-height: 0; overflow-y: auto; + padding-right: 0.75rem; .scroll-buffer { display: flex; diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index 8e4c277..845c02d 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -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; diff --git a/src/templates/apps/gameboard/_partials/_game_kit_sections.html b/src/templates/apps/gameboard/_partials/_game_kit_sections.html index b8d1440..3c0fdec 100644 --- a/src/templates/apps/gameboard/_partials/_game_kit_sections.html +++ b/src/templates/apps/gameboard/_partials/_game_kit_sections.html @@ -77,5 +77,18 @@ {% endif %} + + {% if entry.applet.slug == 'pronouns' and entry.visible %} + + Pronouns + + {% for opt in pronoun_options %} + + {{ opt.key }} + + {% endfor %} + + + {% endif %} {% endfor %}