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 %}