Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a --sixBlt: 162,170,173 neutral) + two .palette-baltimore / .palette-maryland palette classes wiring those hues into the standard --priUser…--decUser slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: _NOTE_DISPLAY (drama/models.py) — {"greeting": "Ayo,", "title": "Ard!"} so DON flips navbar Welcome, Earthman → Ayo, Ard!; _NOTE_TITLES (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; _NOTE_META (billboard/views.py) — Baltimorean title + the literal description "Aaron earned an iron urn." + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; set_pronouns view rewired (dashboard/views.py) — first-time pronouns = bawlmorese selection calls Note.grant_if_new(user, "baltimorean") + returns {"brief": brief.to_banner_dict()} JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns commit() handles the 200 JSON path — resp.json().then(data => Brief.showBanner(data.brief)) instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; game_kit.html pulls in apps/dashboard/note.js so Brief is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — note.js showBanner() now measures the .row .col-lg-6 h2 at render-time + sets inline top so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default top: 0.5rem (which had it lurking above the wordmark area on every page); portrait-only (gated if window.innerWidth > window.innerHeight return) — landscape h2 lives in a writing-mode: vertical-rl fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for _NOTE_DISPLAY[baltimorean] greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — SetPronounsBawlmoreseUnlockTest (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title Ard! / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing SetPronounsViewTest.test_post_each_valid_choice docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. Ard! title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting Welcome, Earthman → Ayo, Ard!; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. data-description carrying Baltimorean + non-empty data-unlocked-date + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
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:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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="/")
|
||||
|
||||
Reference in New Issue
Block a user