From 435a19234908a77be19008d605c8716bec06c1d5 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 18 May 2026 02:17:07 -0400 Subject: [PATCH] =?UTF-8?q?Baltimorean=20Note=20unlock=20loop=20=E2=80=94?= =?UTF-8?q?=20full=20UX=20from=20bawlmorese=20pronoun=20pick=20=E2=86=92?= =?UTF-8?q?=20Brief=20banner=20=E2=86=92=20DON=20=E2=86=92=20palette=20mod?= =?UTF-8?q?al=20=E2=86=92=20dashboard=20swatch=20;=20rootvars.scss=20adds?= =?UTF-8?q?=20the=20Baltimorean=20(Blt)=20hue=20family=20(red=20200,16,46?= =?UTF-8?q?=20/=20yellow=20255,212,0=20/=20white=20255,255,255=20/=20black?= =?UTF-8?q?=200,0,0=20/=20purple=2026,25,95=20/=20orange=20221,73,38=20?= =?UTF-8?q?=E2=80=94=20Maryland-flag-derived=20plus=20a=20`--sixBlt:=20162?= =?UTF-8?q?,170,173`=20neutral)=20+=20two=20`.palette-baltimore`=20/=20`.p?= =?UTF-8?q?alette-maryland`=20palette=20classes=20wiring=20those=20hues=20?= =?UTF-8?q?into=20the=20standard=20`--priUser`=E2=80=A6`--decUser`=20slots?= =?UTF-8?q?;=20companion=20section-header=20rename=20"/*=20X=20Palette=20*?= =?UTF-8?q?/"=20=E2=86=92=20"/*=20X=20Hues=20*/"=20across=20rootvars=20to?= =?UTF-8?q?=20disambiguate=20raw=20hue=20families=20(Precious=20Metal=20/?= =?UTF-8?q?=20Cosmic=20Metal=20/=20Chroma=20/=20Earthman=20/=20Technoman?= =?UTF-8?q?=20/=20Inferno)=20from=20actual=20palette=20classes=20=E2=80=94?= =?UTF-8?q?=20section-comment-only,=20no=20rule-level=20change=20;=20balti?= =?UTF-8?q?morean=20entry=20added=20in=203=20registries=20that=20drive=20t?= =?UTF-8?q?he=20loop:=20`=5FNOTE=5FDISPLAY`=20(drama/models.py)=20?= =?UTF-8?q?=E2=80=94=20`{"greeting":=20"Ayo,",=20"title":=20"Ard!"}`=20so?= =?UTF-8?q?=20DON=20flips=20navbar=20`Welcome,=20Earthman`=20=E2=86=92=20`?= =?UTF-8?q?Ayo,=20Ard!`;=20`=5FNOTE=5FTITLES`=20(dashboard/views.py,=20use?= =?UTF-8?q?r-pre-staged)=20=E2=80=94=20drives=20the=20"recognized=20via=20?= =?UTF-8?q?Baltimorean"=20copy=20on=20dashboard=20palette=20swatches;=20`?= =?UTF-8?q?=5FNOTE=5FMETA`=20(billboard/views.py)=20=E2=80=94=20Baltimorea?= =?UTF-8?q?n=20title=20+=20the=20literal=20description=20`"Aaron=20earned?= =?UTF-8?q?=20an=20iron=20urn."`=20+=20palette=5Foptions=20[palette-baltim?= =?UTF-8?q?ore,=20palette-maryland]=20feeding=20the=20my-notes=20swatch=20?= =?UTF-8?q?modal=20;=20`set=5Fpronouns`=20view=20rewired=20(dashboard/view?= =?UTF-8?q?s.py)=20=E2=80=94=20first-time=20`pronouns=20=3D=20bawlmorese`?= =?UTF-8?q?=20selection=20calls=20`Note.grant=5Fif=5Fnew(user,=20"baltimor?= =?UTF-8?q?ean")`=20+=20returns=20`{"brief":=20brief.to=5Fbanner=5Fdict()}?= =?UTF-8?q?`=20JSON=20@=20200;=20idempotent=20on=20repeat=20(the=20grant?= =?UTF-8?q?=5Fif=5Fnew=20returns=20brief=3DNone=20on=20second=20call=20so?= =?UTF-8?q?=20the=20204=20path=20resumes=20naturally);=20non-bawlmorese=20?= =?UTF-8?q?choices=20stay=20on=20the=20original=20204=20contract=20;=20cli?= =?UTF-8?q?ent=20wiring:=20game-kit.js=20pronouns=20`commit()`=20handles?= =?UTF-8?q?=20the=20200=20JSON=20path=20=E2=80=94=20`resp.json().then(data?= =?UTF-8?q?=20=3D>=20Brief.showBanner(data.brief))`=20instead=20of=20reloa?= =?UTF-8?q?d=20(reload=20would=20lose=20the=20just-fired=20banner);=20204?= =?UTF-8?q?=20still=20reloads=20to=20update=20active=20pronoun=20card;=20`?= =?UTF-8?q?game=5Fkit.html`=20pulls=20in=20`apps/dashboard/note.js`=20so?= =?UTF-8?q?=20`Brief`=20is=20in=20scope=20on=20the=20Game=20Kit=20page=20(?= =?UTF-8?q?it=20wasn't=20before)=20;=20Brief=20banner=20placement=20fix=20?= =?UTF-8?q?=E2=80=94=20`note.js=20showBanner()`=20now=20measures=20the=20`?= =?UTF-8?q?.row=20.col-lg-6=20h2`=20at=20render-time=20+=20sets=20inline?= =?UTF-8?q?=20`top`=20so=20the=20banner=20portals=20SQUARELY=20OVER=20the?= =?UTF-8?q?=20page=20h2=20letter-spread=20wordmark=20instead=20of=20parkin?= =?UTF-8?q?g=20at=20the=20SCSS-default=20`top:=200.5rem`=20(which=20had=20?= =?UTF-8?q?it=20lurking=20above=20the=20wordmark=20area=20on=20every=20pag?= =?UTF-8?q?e);=20portrait-only=20(gated=20`if=20window.innerWidth=20>=20wi?= =?UTF-8?q?ndow.innerHeight=20return`)=20=E2=80=94=20landscape=20h2=20live?= =?UTF-8?q?s=20in=20a=20`writing-mode:=20vertical-rl`=20fixed=20sidebar=20?= =?UTF-8?q?column=20+=20would=20need=20a=20full=20banner=20reorientation?= =?UTF-8?q?=20(writing-mode=20+=20flex-direction=20restyle=20of=20banner?= =?UTF-8?q?=20contents)=20to=20"overlay"=20sensibly,=20deferred=20to=20a?= =?UTF-8?q?=20follow-up=20sprint=20;=20tests:=20drama/tests/unit/test=5Fmo?= =?UTF-8?q?dels.py=20(new=20file)=20=E2=80=94=205=20UTs=20for=20`=5FNOTE?= =?UTF-8?q?=5FDISPLAY[baltimorean]`=20greeting/title/name=20+=20stargazer?= =?UTF-8?q?=20smoke=20tests;=20dashboard/tests/integrated/test=5Fviews.py?= =?UTF-8?q?=20=E2=80=94=20`SetPronounsBawlmoreseUnlockTest`=20(9=20ITs=20c?= =?UTF-8?q?overing=20first-bawlmorese-returns-200-w-brief=20/=20Note=20gra?= =?UTF-8?q?nted=20/=20title=20`Ard!`=20/=20square=5Furl=20to=20/billboard/?= =?UTF-8?q?my-notes/=20/=20idempotent=20on=20repeat=20/=20non-bawlmorese?= =?UTF-8?q?=20unaffected=20/=20bawlmorese-after-other=20still=20grants);?= =?UTF-8?q?=20existing=20`SetPronounsViewTest.test=5Fpost=5Feach=5Fvalid?= =?UTF-8?q?=5Fchoice`=20docstring=20updated=20to=20flag=20the=20bawlmorese?= =?UTF-8?q?=20200=20branch=20;=20functional=5Ftests/test=5Fbill=5Fbaltimor?= =?UTF-8?q?ean.py=20(new=20file)=20=E2=80=94=206=20FTs=20walking=20the=20f?= =?UTF-8?q?ull=20UX:=20T1=20Game-Kit=20pronouns=20click=20=E2=86=92=20Brie?= =?UTF-8?q?f=20banner=20w.=20`Ard!`=20title=20+=20Look!=20prose=20+=20=3F-?= =?UTF-8?q?square=20+=20FYI=20nav;=20T2=20idempotent=20repeat-click=20(no?= =?UTF-8?q?=20re-fire);=20T3=20my-notes=20Baltimorean=20item=20carries=20t?= =?UTF-8?q?he=20Aaron=20quote=20verbatim;=20T4=20DON=20flips=20navbar=20gr?= =?UTF-8?q?eeting=20`Welcome,=20Earthman`=20=E2=86=92=20`Ayo,=20Ard!`;=20T?= =?UTF-8?q?5=20palette=20modal=20offers=20Baltimore=20+=20Maryland=20swatc?= =?UTF-8?q?hes=20(and=20not=20Bardo/Sheol);=20T6=20Baltimore=20swatch=20cl?= =?UTF-8?q?ick=20previews=20=E2=86=92=20OK=20commits=20=E2=86=92=20dashboa?= =?UTF-8?q?rd=20Palette=20applet=20shows=20the=20swatch=20unlocked=20w.=20?= =?UTF-8?q?`data-description`=20carrying=20`Baltimorean`=20+=20non-empty?= =?UTF-8?q?=20`data-unlocked-date`=20+=20Note.palette=20=3D=20palette-balt?= =?UTF-8?q?imore=20in=20DB=20=E2=80=94=20all=206=20green=20in=2051s;=20ful?= =?UTF-8?q?l=20IT/UT=20sweep=20997=20=E2=86=92=20green=20in=2045s=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/billboard/views.py | 6 + .../dashboard/static/apps/dashboard/note.js | 25 ++ .../dashboard/tests/integrated/test_views.py | 75 ++++ src/apps/dashboard/views.py | 11 + src/apps/drama/models.py | 1 + src/apps/drama/tests/unit/__init__.py | 0 src/apps/drama/tests/unit/test_models.py | 39 +++ .../static/apps/gameboard/game-kit.js | 10 + src/functional_tests/test_bill_baltimorean.py | 321 ++++++++++++++++++ src/static_src/scss/rootvars.scss | 61 +++- src/templates/apps/gameboard/game_kit.html | 3 + 11 files changed, 544 insertions(+), 8 deletions(-) create mode 100644 src/apps/drama/tests/unit/__init__.py create mode 100644 src/apps/drama/tests/unit/test_models.py create mode 100644 src/functional_tests/test_bill_baltimorean.py diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 3c3a11a..cee7604 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -179,6 +179,12 @@ _NOTE_META = { "palette_options": [], "swatch_label": "0", }, + "baltimorean": { + "title": "Baltimorean", + "description": '"Aaron earned an iron urn."', + "palette_options": _palette_opts(["palette-baltimore", "palette-maryland"]), + "swatch_label": None, + }, } diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index c3c806d..7102c12 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -63,6 +63,31 @@ const Brief = (() => { } else { document.body.insertBefore(banner, document.body.firstChild); } + _alignToH2(banner); + } + + // Slot the (position:fixed) banner over the page's h2 letter-spread + // header so it visually portals across the wordmark rather than parking + // at the top of the viewport. + // + // Portrait only: h2 is a horizontal wordmark in `.container .row`; its + // top edge is navbar-height + row-padding which differs per viewport, + // so we measure at render-time + set inline `top`. + // + // Landscape: h2 is rotated into the fixed --h2-col-w sidebar slot via + // `writing-mode: vertical-rl`. A horizontally-flowing banner can't + // "overlay" a vertical h2 without a full reorientation of its own + // contents (writing-mode + flex-direction restyle). Defer that to a + // landscape-vertical-banner sprint; for now landscape keeps the SCSS + // default `top: 0.5rem` (banner reads as a horizontal strip near the + // top of the page content area). + function _alignToH2(banner) { + if (window.innerWidth > window.innerHeight) return; // landscape: skip + var h2 = document.querySelector('.row .col-lg-6 h2'); + if (!h2) return; + var rect = h2.getBoundingClientRect(); + if (rect.height <= 0) return; + banner.style.top = rect.top + 'px'; } function handleSaveResponse(data) { diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 4f2fe0a..95f6ce9 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -714,6 +714,10 @@ class SetPronounsViewTest(TestCase): self.assertEqual(self.user.pronouns, "misogyny") def test_post_each_valid_choice(self): + """Every PRONOUN_TABLE key persists. Note: bawlmorese also triggers the + Baltimorean Note-unlock JSON response (status 200) on first call — + covered separately by SetPronounsBawlmoreseUnlockTest. Here we just + confirm the user.pronouns write lands for each choice.""" for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"): with self.subTest(key=key): self.client.post(self.url, data={"pronouns": key}) @@ -735,3 +739,74 @@ class SetPronounsViewTest(TestCase): self.client.logout() response = self.client.post(self.url, data={"pronouns": "misogyny"}) self.assertEqual(response.status_code, 302) + + +class SetPronounsBawlmoreseUnlockTest(TestCase): + """First-time selection of `pronouns = bawlmorese` grants the Baltimorean + Note + returns a Brief banner payload (similar to sky_save's stargazer + unlock). Subsequent selections are idempotent — no new Note, no new + Brief, status 204 like other pronoun choices.""" + + def setUp(self): + self.user = User.objects.create(email="aaron@bawlmore.io") + self.client.force_login(self.user) + self.url = reverse("set_pronouns") + + def test_first_bawlmorese_returns_200_with_brief_json(self): + response = self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("brief", data) + self.assertIsNotNone(data["brief"]) + + def test_first_bawlmorese_grants_baltimorean_note(self): + self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.assertTrue( + Note.objects.filter(user=self.user, slug="baltimorean").exists() + ) + + def test_brief_payload_carries_baltimorean_title(self): + response = self.client.post(self.url, data={"pronouns": "bawlmorese"}) + brief = response.json()["brief"] + self.assertEqual(brief["title"], "Ard!") + + def test_brief_payload_square_url_jumps_to_my_notes(self): + """NOTE_UNLOCK Briefs carry a `square_url` pointing at /billboard/my-notes/ + so the banner's `?` square jumps straight to the user's Note list.""" + response = self.client.post(self.url, data={"pronouns": "bawlmorese"}) + brief = response.json()["brief"] + self.assertEqual(brief["square_url"], "/billboard/my-notes/") + + def test_second_bawlmorese_is_idempotent_no_new_note(self): + self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.assertEqual( + Note.objects.filter(user=self.user, slug="baltimorean").count(), 1 + ) + + def test_second_bawlmorese_returns_204(self): + """No fresh Brief on the repeat call — status returns to the no-payload + 204 that other pronoun choices use.""" + self.client.post(self.url, data={"pronouns": "bawlmorese"}) + response = self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.assertEqual(response.status_code, 204) + + def test_non_bawlmorese_choice_does_not_grant_baltimorean(self): + self.client.post(self.url, data={"pronouns": "misogyny"}) + self.assertFalse( + Note.objects.filter(user=self.user, slug="baltimorean").exists() + ) + + def test_non_bawlmorese_choice_still_returns_204(self): + response = self.client.post(self.url, data={"pronouns": "misogyny"}) + self.assertEqual(response.status_code, 204) + + def test_bawlmorese_after_other_choice_still_grants_note(self): + """User picks misogyny first, then bawlmorese — the first bawlmorese + click is still the unlock moment regardless of prior pronoun history.""" + self.client.post(self.url, data={"pronouns": "misogyny"}) + response = self.client.post(self.url, data={"pronouns": "bawlmorese"}) + self.assertEqual(response.status_code, 200) + self.assertTrue( + Note.objects.filter(user=self.user, slug="baltimorean").exists() + ) diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 3c3ba65..9881e7c 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -38,11 +38,14 @@ _PALETTE_DEFS = [ {"name": "palette-inferno", "label": "Inferno", "locked": True}, {"name": "palette-terrestre", "label": "Terrestre", "locked": True}, {"name": "palette-celestia", "label": "Celestia", "locked": True}, + {"name": "palette-baltimore", "label": "Baltimore", "locked": True}, + {"name": "palette-maryland", "label": "Maryland", "locked": True}, ] _NOTE_TITLES = { "stargazer": "Stargazer", "schizo": "Schizo", "nomad": "Nomad", + "baltimorean": "Baltimorean", } # Keep PALETTES as an alias used by views that don't have a request user. PALETTES = _PALETTE_DEFS @@ -131,6 +134,14 @@ def set_pronouns(request): return HttpResponse(status=400) request.user.pronouns = choice request.user.save(update_fields=["pronouns"]) + # bawlmorese is the pronoun-side trigger for the Baltimorean Note unlock — + # mirrors sky_save's stargazer grant. Grant is idempotent (grant_if_new + # returns no Brief on the second + later calls) so the 204 path resumes + # naturally after the first unlock. + if choice == "bawlmorese": + _note, _created, brief = Note.grant_if_new(request.user, "baltimorean") + if brief is not None: + return JsonResponse({"brief": brief.to_banner_dict()}) return HttpResponse(status=204) @login_required(login_url="/") diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index d50b812..e129572 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -212,6 +212,7 @@ _NOTE_DISPLAY = { "nomad": {"greeting": "Welcome,", "title": "Nomad"}, "super-schizo": {"greeting": "21st Century", "title": "Schizoid Man"}, "super-nomad": {"greeting": "Howdy,", "title": "Stranger"}, + "baltimorean": {"greeting": "Ayo,", "title": "Ard!"}, } # Note slugs whose grant prose uses the long admin format ("The administration diff --git a/src/apps/drama/tests/unit/__init__.py b/src/apps/drama/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/drama/tests/unit/test_models.py b/src/apps/drama/tests/unit/test_models.py new file mode 100644 index 0000000..a947ce5 --- /dev/null +++ b/src/apps/drama/tests/unit/test_models.py @@ -0,0 +1,39 @@ +from django.test import SimpleTestCase + +from apps.drama.models import Note + + +def _note(slug): + n = Note() + n.slug = slug + return n + + +class NoteDisplayBaltimoreanTest(SimpleTestCase): + """`_NOTE_DISPLAY['baltimorean']` — greeting/title combo "Ayo, Ard!" replaces + "Welcome, Earthman" once the user dons the Baltimorean Note (unlocked by + first-time `pronouns = bawlmorese` selection).""" + + def test_baltimorean_display_title_is_ard(self): + self.assertEqual(_note("baltimorean").display_title, "Ard!") + + def test_baltimorean_display_greeting_is_ayo(self): + self.assertEqual(_note("baltimorean").display_greeting, "Ayo,") + + def test_baltimorean_display_name_is_baltimorean(self): + """The my-notes heading label — `.title()` of the slug recovers the + proper rendering for any slug without a `name` override in + `_NOTE_DISPLAY`. Baltimorean has no override, so this should fall + through to `slug.title()` → 'Baltimorean'.""" + self.assertEqual(_note("baltimorean").display_name, "Baltimorean") + + +class NoteDisplayStargazerTest(SimpleTestCase): + """Smoke tests for the existing stargazer entry — pins the contract that + new entries follow.""" + + def test_stargazer_display_title_is_stargazer(self): + self.assertEqual(_note("stargazer").display_title, "Stargazer") + + def test_stargazer_display_greeting_is_welcome(self): + self.assertEqual(_note("stargazer").display_greeting, "Welcome,") diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js index 403a00e..4554e3e 100644 --- a/src/apps/gameboard/static/apps/gameboard/game-kit.js +++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js @@ -450,6 +450,16 @@ var GameKit = (function () { credentials: 'same-origin', body: body.toString(), }).then(function (resp) { + if (resp.status === 200) { + // First-time `bawlmorese` selection — server returns the + // Baltimorean Brief payload to render inline (no reload, or the + // banner would be lost the moment the page navigates). + return resp.json().then(function (data) { + if (data && data.brief && typeof Brief !== 'undefined') { + Brief.showBanner(data.brief); + } + }); + } 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. diff --git a/src/functional_tests/test_bill_baltimorean.py b/src/functional_tests/test_bill_baltimorean.py new file mode 100644 index 0000000..fcf73fa --- /dev/null +++ b/src/functional_tests/test_bill_baltimorean.py @@ -0,0 +1,321 @@ +"""Functional tests for the Baltimorean Note unlock loop. + +Mirrors the Stargazer unlock pattern (see test_bill_my_notes.py) but the +trigger lives on Game Kit's Pronouns applet instead of the My Sky save — +selecting `bawlmorese` as the global pronoun for the first time grants the +Baltimorean Note and fires the slide-down Brief banner. + +End-to-end loop captured here: + 1. Game Kit → pronouns applet → click `bawlmorese` card → guard portal "OK" + 2. Brief banner slides in with title "Ard!" + Look! prose + FYI/NVM/?-square + 3. Navigate to /billboard/my-notes/ → Baltimorean item w. the Aaron quote + 4. DON the Baltimorean Note → navbar greeting flips + "Welcome, Earthman" → "Ayo, Ard!" + 5. Click the ? swatch box → modal opens w. Baltimore + Maryland swatches + 6. Click a Baltimore swatch body → body class previews palette-baltimore + 7. Click OK in the confirm bar → palette commits (Note.palette saves, + user.palette saves, ? box replaced by palette swatch) + 8. Navigate back to /dashboard/ → Palette applet shows Baltimore swatch + unlocked + data-description carrying "Baltimorean" attribution + +Each scenario is its own test method so a regression in any segment fails +in isolation (and the slowest segments — Game Kit pronouns flip vs palette +modal commit — run independently in --parallel). +""" +import time + +from django.test import tag +from django.utils import timezone +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.drama.models import Note +from apps.lyric.models import User + +from .base import FunctionalTest + + +def _setup_applets(): + """Both surfaces this loop visits need their applets registered.""" + Applet.objects.get_or_create( + slug="pronouns", + defaults={"name": "Pronouns", "grid_cols": 6, "grid_rows": 6, "context": "game-kit"}, + ) + Applet.objects.get_or_create( + slug="palette", + defaults={"name": "Palette", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + Applet.objects.get_or_create( + slug="notes", + defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "context": "billboard"}, + ) + + +class BaltimoreanNoteFromGameKitTest(FunctionalTest): + """The full Baltimorean unlock loop. Setup creates a fresh gamer; each + test seeds either nothing (the unlock-moment tests) or a pre-granted + Note (the post-unlock tests) so the surfaces under test are isolated.""" + + def setUp(self): + super().setUp() + # Portrait viewport — kit dialog + my-notes layouts both target the + # narrow layout in their respective FT suites (mirrors test_dash_palette + # + test_bill_my_notes conventions). 800x1200 keeps the navbar h1 + + # guard portal + brief banner all in-frame. + self.browser.set_window_size(800, 1200) + _setup_applets() + self.gamer = User.objects.create(email="ard@bawlmore.io") + self.game_kit_url = self.live_server_url + "/gameboard/game-kit/" + self.my_notes_url = self.live_server_url + "/billboard/my-notes/" + + # ── helpers ────────────────────────────────────────────────────────── + + def _click_bawlmorese_with_guard_ok(self): + """Game Kit pronouns flow: click bawlmorese card → guard "OK" → commit. + Uses execute_script for the OK click to bypass any in-flight scroll- + into-view contention (same pattern as test_core_bud_btn helpers).""" + card = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, '.gk-pronoun-card[data-pronoun="bawlmorese"]' + )) + card.click() + guard_ok = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes" + )) + self.browser.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", + guard_ok, + ) + + # ── T1 — Game-Kit pronouns click fires the Brief banner ───────────── + + def test_first_bawlmorese_click_fires_baltimorean_brief_banner(self): + """First `bawlmorese` selection from Game Kit's Pronouns applet must + slide in a Brief banner carrying the Baltimorean title + a square that + jumps to my-notes. The FYI button navigates to the underlying Post.""" + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.game_kit_url) + + # No banner on the cold page + self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"), + "Brief banner must not be present before the unlock click", + ) + + self._click_bawlmorese_with_guard_ok() + + banner = self.wait_for_slow(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".note-banner" + )) + # The title slot carries the recognition title — for baltimorean + # that's "Ard!" (vs stargazer's "Stargazer"). + self.assertIn( + "Ard!", + banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text, + ) + # Look! prose lands in the description. + desc_text = banner.find_element( + By.CSS_SELECTOR, ".note-banner__description" + ).text + self.assertIn("Look!", desc_text) + # Banner gets a timestamp + the ? square anchored to /billboard/my-notes/. + banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp") + square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image") + self.assertEqual(square.tag_name, "a") + self.assertEqual( + square.get_attribute("href").rstrip("/"), + self.live_server_url + "/billboard/my-notes", + ) + # FYI navigates to the Brief's underlying Post detail. + fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") + fyi.click() + self.wait_for(lambda: self.assertRegex( + self.browser.current_url, r"/billboard/post/[0-9a-f-]+/" + )) + + # ── T2 — Idempotence: re-selecting bawlmorese does NOT re-fire banner + + def test_second_bawlmorese_click_does_not_re_fire_banner(self): + """The Baltimorean Note grant is idempotent — clicking bawlmorese + again after it's already the user's pronouns + Note is granted must + not slide in another banner.""" + # Pre-grant — short-circuits the Brief on the subsequent click. + Note.objects.create( + user=self.gamer, slug="baltimorean", earned_at=timezone.now(), + ) + self.gamer.pronouns = "bawlmorese" + self.gamer.save() + + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.game_kit_url) + self._click_bawlmorese_with_guard_ok() + # The commit reloads — wait for the page to settle, then assert + # no banner. Give the reload + render a beat. + time.sleep(1.2) + self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"), + "Repeat bawlmorese click must not re-fire the Brief banner", + ) + + # ── T3 — my-notes page renders the Baltimorean item w. the Aaron quote + + def test_my_notes_baltimorean_item_renders_aaron_quote(self): + """Once the Note exists, /billboard/my-notes/ shows a Baltimorean + item whose description is literally the Aaron quote (the central + joke of the Note).""" + Note.objects.create( + user=self.gamer, slug="baltimorean", earned_at=timezone.now(), + ) + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.my_notes_url) + + item = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, '.note-list .note-item[data-slug="baltimorean"]' + )) + self.assertIn( + "Baltimorean", + item.find_element(By.CSS_SELECTOR, ".note-item__title").text, + ) + # The Aaron quote is the description verbatim, including the + # surrounding double-quotes that the user spec'd. + self.assertIn( + '"Aaron earned an iron urn."', + item.find_element(By.CSS_SELECTOR, ".note-item__description").text, + ) + + # ── T4 — DON flips the navbar greeting to "Ayo, Ard!" + + def test_don_baltimorean_changes_navbar_greeting(self): + """Pressing DON on the Baltimorean note item rewrites the navbar h1 + greeting from "Welcome, Earthman" to "Ayo, Ard!".""" + Note.objects.create( + user=self.gamer, slug="baltimorean", earned_at=timezone.now(), + ) + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.my_notes_url) + + # Pre-condition: cold navbar greeting + prefix = self.browser.find_element(By.ID, "id_greeting_prefix") + name = self.browser.find_element(By.ID, "id_greeting_name") + self.assertEqual(prefix.text.strip().rstrip(","), "Welcome") + self.assertEqual(name.text.strip(), "Earthman") + + # Click-lock the item first (the note-page locks on a click before + # the DON button becomes interactive — mirrors the test_bill_my_notes + # opacity:0 pattern). + item = self.browser.find_element( + By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]' + ) + item.click() + don = item.find_element(By.CSS_SELECTOR, ".note-don-btn") + self.browser.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", + don, + ) + + # The greeting flips client-side via the don_title JSON response. + self.wait_for(lambda: self.assertEqual( + self.browser.find_element(By.ID, "id_greeting_prefix").text.strip().rstrip(","), + "Ayo", + )) + self.assertEqual( + self.browser.find_element(By.ID, "id_greeting_name").text.strip(), + "Ard!", + ) + + # ── T5 — palette modal opens to Baltimore + Maryland swatches + + def test_palette_modal_shows_baltimore_and_maryland_swatches(self): + """The ? swatch box opens a modal carrying exactly the two + palettes Baltimorean unlocks: Baltimore + Maryland.""" + Note.objects.create( + user=self.gamer, slug="baltimorean", earned_at=timezone.now(), + ) + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.my_notes_url) + + item = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]' + )) + # Click-lock the item, then click the ? box. + item.click() + image_box = item.find_element(By.CSS_SELECTOR, ".note-item__image-box") + image_box.click() + modal = self.wait_for(lambda: item.find_element( + By.CSS_SELECTOR, ".note-palette-modal" + )) + # Both swatches present, each w. its human-readable label. + labels = [ + el.text.strip() + for el in modal.find_elements(By.CSS_SELECTOR, ".note-swatch-label") + ] + self.assertIn("Baltimore", labels) + self.assertIn("Maryland", labels) + # Bardo + Sheol (stargazer's palettes) must NOT bleed through. + self.assertNotIn("Bardo", labels) + self.assertNotIn("Sheol", labels) + + # ── T6 — Baltimore swatch commit + dashboard reflects unlock + ts + + def test_palette_baltimore_commit_persists_and_unlocks_dashboard_swatch(self): + """Clicking the Baltimore swatch body previews palette-baltimore on + the body; OK commits — Note.palette + user.palette both saved — so + navigating to /dashboard/ shows palette-baltimore active + the + Palette applet swatch unlocked with Baltimorean shoptalk + + the unlocked-date timestamp wired through.""" + note = Note.objects.create( + user=self.gamer, slug="baltimorean", earned_at=timezone.now(), + ) + self.create_pre_authenticated_session("ard@bawlmore.io") + self.browser.get(self.my_notes_url) + + item = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]' + )) + item.click() + item.find_element(By.CSS_SELECTOR, ".note-item__image-box").click() + modal = self.wait_for(lambda: item.find_element( + By.CSS_SELECTOR, ".note-palette-modal" + )) + baltimore_body = modal.find_element( + By.CSS_SELECTOR, ".palette-baltimore .note-swatch-body" + ) + self.browser.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", + baltimore_body, + ) + # Body previews the palette + self.wait_for(lambda: self.assertIn( + "palette-baltimore", + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + )) + # OK commits + confirm = self.wait_for(lambda: item.find_element( + By.CSS_SELECTOR, ".note-palette-confirm" + )) + confirm.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click() + self.wait_for(lambda: self.assertFalse( + item.find_elements(By.CSS_SELECTOR, ".note-palette-modal") + )) + + # Navigate to dashboard — palette-baltimore persists on body + + # the Palette applet swatch is unlocked w. the "recognized via + # Baltimorean" data-description + a non-empty unlocked-date. + self.browser.get(self.live_server_url) + self.wait_for(lambda: self.assertIn( + "palette-baltimore", + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + )) + palette_applet = self.wait_for(lambda: self.browser.find_element( + By.ID, "id_applet_palette" + )) + baltimore = palette_applet.find_element( + By.CSS_SELECTOR, ".swatch.palette-baltimore" + ) + self.assertNotIn("locked", baltimore.get_attribute("class")) + self.assertIn("Baltimorean", baltimore.get_attribute("data-description")) + # Unlocked-date carries the Note's earned_at as ISO — non-empty proves + # the wire from Note.earned_at through _palettes_for_user. + self.assertTrue(baltimore.get_attribute("data-unlocked-date")) + # And the same Note row in DB now carries the palette commit. + note.refresh_from_db() + self.assertEqual(note.palette, "palette-baltimore") diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index f6078ab..5edc5aa 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -14,7 +14,7 @@ :root { /* rgb Variable Index */ - /* Precious Metal Palette */ + /* Precious Metal Hues */ // nickel --priNi: 141, 142, 140; --secNi: 118, 120, 118; @@ -59,7 +59,7 @@ --quiAg: 175, 175, 175; --sixAg: 240, 240, 240; - /* Cosmic Metal Palette */ + /* Cosmic Metal Hues */ // mercury (Mercury) --priHg: 23, 31, 51; --secHg: 51, 62, 87; @@ -117,7 +117,7 @@ --quiPu: 189, 175, 214; --sixPu: 235, 211, 217; - /* Chroma Palette */ + /* Chroma Hues */ // red (A-Fire) --priRd: 233, 53, 37; --secRd: 193, 43, 28; @@ -203,7 +203,7 @@ --quiMe: 89, 0, 48; --sixMe: 59, 0, 32; - /* Earthman Palette */ + /* Earthman Hues */ // bark --priBrk: 162, 103, 98; --secBrk: 117, 78, 68; @@ -233,7 +233,7 @@ --secFor: 94, 124, 61; --terFor: 74, 102, 43; - /* Technoman Palette */ + /* Technoman Hue */ // carbon steel // stainless steel // maraging steel @@ -253,7 +253,7 @@ --tooltip-bg: 0, 0, 0; --title-shadow-offset: -0.125rem; - /* Inferno Palette (4 per) */ + /* Inferno Hues (4 per) */ // mist (Elpis's Lethe) --priMst: 168, 202, 172; --secMst: 103, 145, 105; @@ -282,7 +282,7 @@ --terIce: 74, 119, 125; --quaIce: 35, 65, 75; - /* Terrestre Palette (6 per) */ + /* Terrestre Hues (6 per) */ // crumbling perse (Contrition) --priPer: 34, 30, 77; --secPer: 52, 45, 99; @@ -304,7 +304,7 @@ --quiAdm: 197, 213, 228; --sixAdm: 226, 244, 253; - /* Emanation Palettes */ + /* Emanation Hues */ // Plant Bundle // • beige-pink (streetlamps) --priBpk: 223, 159, 140; @@ -336,6 +336,23 @@ --ninClh: 192, 77, 1; --decClh: 255, 174, 0; + /* Lord Baltimore Hues */ + // yellow + --priBlt: 235, 171, 0; + --secBlt: 187, 147, 52; + // white + --terBlt: 255, 255, 255; + // --quaBlt: ; + // black + --quiBlt: 0, 0, 0; + --sixBlt: 162, 170, 173; + // purple + --sepBlt: 26, 25, 95; + --octBlt: 157, 34, 53; + // orange + --ninBlt: 221, 73, 38; + // --decBlt: ; + // Felt values --undUser: var(--priFor); --duoUser: var(--terFor); @@ -423,6 +440,34 @@ /* Nebula Palette */ +/* Baltimore Palette */ +.palette-baltimore { + --priUser: var(--sepBlt); + --secUser: var(--terBlt); + --terUser: var(--ninBlt); + --quaUser: var(--priYl); + --quiUser: var(--priBlt); + --sixUser: var(--quiBlt); + --sepUser: var(--quiBlt); + --octUser: var(--quiBlt); + --ninUser: var(--sixBlt); + --decUser: var(--quiBlt); +} + +/* Maryland Palette */ +.palette-maryland { + --priUser: var(--quiBlt); + --secUser: var(--sixBlt); + --terUser: var(--octBlt); + --quaUser: var(--priBlt); + --quiUser: var(--secBlt); + --sixUser: var(--quiBlt); + --sepUser: var(--quiBlt); + --octUser: var(--quiBlt); + --ninUser: var(--priRd); + --decUser: var(--quiBlt); +} + /* Monochrome Dark Palette */ .palette-monochrome-dark { --priUser: var(--priAg); /* 30,30,30 — near-black bg */ diff --git a/src/templates/apps/gameboard/game_kit.html b/src/templates/apps/gameboard/game_kit.html index 5c18c5e..c0d5fad 100644 --- a/src/templates/apps/gameboard/game_kit.html +++ b/src/templates/apps/gameboard/game_kit.html @@ -38,5 +38,8 @@ {% block scripts %} + {# Brief.showBanner — needed for the Baltimorean Note-unlock banner the #} + {# pronouns applet fires on first `bawlmorese` selection. #} + {% endblock scripts %}