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, EarthmanAyo, 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, EarthmanAyo, 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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-18 02:17:07 -04:00
parent bc77296dd4
commit 435a192349
11 changed files with 544 additions and 8 deletions

View File

@@ -179,6 +179,12 @@ _NOTE_META = {
"palette_options": [], "palette_options": [],
"swatch_label": "0", "swatch_label": "0",
}, },
"baltimorean": {
"title": "Baltimorean",
"description": '"Aaron earned an iron urn."',
"palette_options": _palette_opts(["palette-baltimore", "palette-maryland"]),
"swatch_label": None,
},
} }

View File

@@ -63,6 +63,31 @@ const Brief = (() => {
} else { } else {
document.body.insertBefore(banner, document.body.firstChild); 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) { function handleSaveResponse(data) {

View File

@@ -714,6 +714,10 @@ class SetPronounsViewTest(TestCase):
self.assertEqual(self.user.pronouns, "misogyny") self.assertEqual(self.user.pronouns, "misogyny")
def test_post_each_valid_choice(self): 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"): for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
with self.subTest(key=key): with self.subTest(key=key):
self.client.post(self.url, data={"pronouns": key}) self.client.post(self.url, data={"pronouns": key})
@@ -735,3 +739,74 @@ class SetPronounsViewTest(TestCase):
self.client.logout() self.client.logout()
response = self.client.post(self.url, data={"pronouns": "misogyny"}) response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 302) 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()
)

View File

@@ -38,11 +38,14 @@ _PALETTE_DEFS = [
{"name": "palette-inferno", "label": "Inferno", "locked": True}, {"name": "palette-inferno", "label": "Inferno", "locked": True},
{"name": "palette-terrestre", "label": "Terrestre", "locked": True}, {"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "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 = { _NOTE_TITLES = {
"stargazer": "Stargazer", "stargazer": "Stargazer",
"schizo": "Schizo", "schizo": "Schizo",
"nomad": "Nomad", "nomad": "Nomad",
"baltimorean": "Baltimorean",
} }
# Keep PALETTES as an alias used by views that don't have a request user. # Keep PALETTES as an alias used by views that don't have a request user.
PALETTES = _PALETTE_DEFS PALETTES = _PALETTE_DEFS
@@ -131,6 +134,14 @@ def set_pronouns(request):
return HttpResponse(status=400) return HttpResponse(status=400)
request.user.pronouns = choice request.user.pronouns = choice
request.user.save(update_fields=["pronouns"]) 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) return HttpResponse(status=204)
@login_required(login_url="/") @login_required(login_url="/")

View File

@@ -212,6 +212,7 @@ _NOTE_DISPLAY = {
"nomad": {"greeting": "Welcome,", "title": "Nomad"}, "nomad": {"greeting": "Welcome,", "title": "Nomad"},
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"}, "super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"}, "super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
"baltimorean": {"greeting": "Ayo,", "title": "Ard!"},
} }
# Note slugs whose grant prose uses the long admin format ("The administration # Note slugs whose grant prose uses the long admin format ("The administration

View File

View File

@@ -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,")

View File

@@ -450,6 +450,16 @@ var GameKit = (function () {
credentials: 'same-origin', credentials: 'same-origin',
body: body.toString(), body: body.toString(),
}).then(function (resp) { }).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) { if (resp.status === 204) {
// Reload so any provenance prose currently on the page renders // Reload so any provenance prose currently on the page renders
// with the new pronouns and the .active class moves to the new card. // with the new pronouns and the .active class moves to the new card.

View File

@@ -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")

View File

@@ -14,7 +14,7 @@
:root { :root {
/* rgb Variable Index */ /* rgb Variable Index */
/* Precious Metal Palette */ /* Precious Metal Hues */
// nickel // nickel
--priNi: 141, 142, 140; --priNi: 141, 142, 140;
--secNi: 118, 120, 118; --secNi: 118, 120, 118;
@@ -59,7 +59,7 @@
--quiAg: 175, 175, 175; --quiAg: 175, 175, 175;
--sixAg: 240, 240, 240; --sixAg: 240, 240, 240;
/* Cosmic Metal Palette */ /* Cosmic Metal Hues */
// mercury (Mercury) // mercury (Mercury)
--priHg: 23, 31, 51; --priHg: 23, 31, 51;
--secHg: 51, 62, 87; --secHg: 51, 62, 87;
@@ -117,7 +117,7 @@
--quiPu: 189, 175, 214; --quiPu: 189, 175, 214;
--sixPu: 235, 211, 217; --sixPu: 235, 211, 217;
/* Chroma Palette */ /* Chroma Hues */
// red (A-Fire) // red (A-Fire)
--priRd: 233, 53, 37; --priRd: 233, 53, 37;
--secRd: 193, 43, 28; --secRd: 193, 43, 28;
@@ -203,7 +203,7 @@
--quiMe: 89, 0, 48; --quiMe: 89, 0, 48;
--sixMe: 59, 0, 32; --sixMe: 59, 0, 32;
/* Earthman Palette */ /* Earthman Hues */
// bark // bark
--priBrk: 162, 103, 98; --priBrk: 162, 103, 98;
--secBrk: 117, 78, 68; --secBrk: 117, 78, 68;
@@ -233,7 +233,7 @@
--secFor: 94, 124, 61; --secFor: 94, 124, 61;
--terFor: 74, 102, 43; --terFor: 74, 102, 43;
/* Technoman Palette */ /* Technoman Hue */
// carbon steel // carbon steel
// stainless steel // stainless steel
// maraging steel // maraging steel
@@ -253,7 +253,7 @@
--tooltip-bg: 0, 0, 0; --tooltip-bg: 0, 0, 0;
--title-shadow-offset: -0.125rem; --title-shadow-offset: -0.125rem;
/* Inferno Palette (4 per) */ /* Inferno Hues (4 per) */
// mist (Elpis's Lethe) // mist (Elpis's Lethe)
--priMst: 168, 202, 172; --priMst: 168, 202, 172;
--secMst: 103, 145, 105; --secMst: 103, 145, 105;
@@ -282,7 +282,7 @@
--terIce: 74, 119, 125; --terIce: 74, 119, 125;
--quaIce: 35, 65, 75; --quaIce: 35, 65, 75;
/* Terrestre Palette (6 per) */ /* Terrestre Hues (6 per) */
// crumbling perse (Contrition) // crumbling perse (Contrition)
--priPer: 34, 30, 77; --priPer: 34, 30, 77;
--secPer: 52, 45, 99; --secPer: 52, 45, 99;
@@ -304,7 +304,7 @@
--quiAdm: 197, 213, 228; --quiAdm: 197, 213, 228;
--sixAdm: 226, 244, 253; --sixAdm: 226, 244, 253;
/* Emanation Palettes */ /* Emanation Hues */
// Plant Bundle // Plant Bundle
// • beige-pink (streetlamps) // • beige-pink (streetlamps)
--priBpk: 223, 159, 140; --priBpk: 223, 159, 140;
@@ -336,6 +336,23 @@
--ninClh: 192, 77, 1; --ninClh: 192, 77, 1;
--decClh: 255, 174, 0; --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 // Felt values
--undUser: var(--priFor); --undUser: var(--priFor);
--duoUser: var(--terFor); --duoUser: var(--terFor);
@@ -423,6 +440,34 @@
/* Nebula Palette */ /* 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 */ /* Monochrome Dark Palette */
.palette-monochrome-dark { .palette-monochrome-dark {
--priUser: var(--priAg); /* 30,30,30 — near-black bg */ --priUser: var(--priAg); /* 30,30,30 — near-black bg */

View File

@@ -38,5 +38,8 @@
{% block scripts %} {% block scripts %}
<script src="{% static 'apps/epic/stage-card.js' %}"></script> <script src="{% static 'apps/epic/stage-card.js' %}"></script>
{# Brief.showBanner — needed for the Baltimorean Note-unlock banner the #}
{# pronouns applet fires on first `bawlmorese` selection. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/gameboard/game-kit.js' %}"></script> <script src="{% static 'apps/gameboard/game-kit.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}